Public Access
The authentication picker has listed saved credentials since they existed, but making one meant leaving a half-typed host for the keychain screen and coming back to find it gone. On the phone it was worse than a detour: that head has no credential editor at all, so it could bind a host to a credential and never produce one. + NEW CREDENTIAL opens a card under the picker — name, optional username, password, notes — and ADD writes it and binds the host in one step. A button beside the picker rather than an entry inside it. Every row of that list is a binding a host can have, and "make a new one" is an action: as an entry it would sit in the box afterwards describing a state no host can be in, and cancelling the form would leave the picker showing it. It carries its own five fields rather than reusing the keychain editor's, and that is the load-bearing part. IsEditingCredential is what AVaultEditorIsInTheWay asks about, so sharing it would have made the whole Vault screen refuse to open an editor while this card sat open on the Hosts screen, with a status line naming a form the user cannot see on a screen they are not looking at — the exact failure that guard was split in two to end. A test pins it. It writes to the keychain immediately, unlike every other field in this editor, because a credential is a shared item with an id and a host can only name an id that exists. The consequence is honest rather than hidden and the hint says so: a credential added this way outlives a cancelled host edit. What was still being typed does not — every path that closes the host editor clears the form, and one of those fields is a password. The binding is written before the reload rather than after it. RefreshOpenEditors rebuilds this picker and then restores it from the editor's own selection, so setting it first is what survives the pass, and by the time it is read ReloadCredentialsAsync has put the matching entry in the list to land on. A name already taken is duplicated, not reused, and that is a deliberate parting from the new-tag box six lines further down which offers the existing tag instead. Two tags called "staging" are one intention spelled twice; two credentials called "root" are two different passwords, and quietly binding the host to whichever was there already would authenticate it as an account nobody chose. A duplicate label in the picker is the smaller problem. Into editingHostVaultId, so the credential lands wherever the host is being sealed and everybody who can read the host can read what it authenticates with. Stricter than the tag path — which files into the active vault and is recorded as a gap in docs/design-import-gaps.md — and it can be, because this picker lists credentials from every readable vault rather than one. Five flow tests cover the bind-through-reload path, the cancel semantics on both the saved credential and the abandoned one, the cross-screen guard, the duplicate name and the empty-password refusal. The layout test is separate and necessary: the card is collapsed until somebody presses the button, so a harness driven by the default state draws none of it, and TheHostDrawerFitsWithTheHostEditorOpen would have gone on passing over a card that blew the column. 1,854 tests, none failing.
8929 lines
357 KiB
C#
8929 lines
357 KiB
C#
using System.Globalization;
|
|
using DodoSSH.Client.Auth;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Import;
|
|
using DodoSSH.Client.ObjectStore;
|
|
using DodoSSH.Client.Session;
|
|
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
|
// see the csproj for why it is shared rather than reimplemented.
|
|
using DodoSSH.Client.Session.Tests;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Sync;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <summary>
|
|
/// The whole path a user walks: sign in, enroll, keep the recovery code, unlock, add a host, sync.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Runs with no Avalonia, no browser and no identity provider, because the view models are plain
|
|
/// observable objects and sign-in is a delegate. What that buys is that the states most likely to be got
|
|
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
|
|
/// rather than by remembering to click through them.
|
|
/// </remarks>
|
|
public sealed class ShellFlowTests : IAsyncLifetime
|
|
{
|
|
private const string Passphrase = "a sufficiently long passphrase";
|
|
|
|
/// <summary>Everything this shell has copied, newest last.</summary>
|
|
private readonly List<string> clipboard = [];
|
|
|
|
/// <remarks>
|
|
/// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
|
|
/// state machine, not about how expensive the passphrase is to attack.
|
|
/// </remarks>
|
|
private static readonly Argon2Profile CheapProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
|
|
|
private readonly FakeVaultServer server = new();
|
|
|
|
/// <remarks>
|
|
/// The real factory would need a reachable sshd, which <c>DodoSSH.Client.Ssh.Tests</c> covers against
|
|
/// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
|
|
/// the connect path reachable.
|
|
/// </remarks>
|
|
private readonly FakeSshConnectionFactory ssh = new();
|
|
|
|
private int signInAttempts;
|
|
|
|
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
|
|
/// <remarks>
|
|
/// Counted rather than merely allowed, because the interesting assertions about resuming are about how
|
|
/// often it happens: once per launch when it works, and never again once the provider has refused.
|
|
/// </remarks>
|
|
private int resumeAttempts;
|
|
|
|
private string? resumedWith;
|
|
|
|
/// <summary>When set, resuming throws — how a revoked or rotated-away token is exercised.</summary>
|
|
private Exception? resumeFailure;
|
|
|
|
private string directory = null!;
|
|
private ClientPaths paths = null!;
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private VaultKnownHostStore knownHosts = null!;
|
|
|
|
/// <remarks>
|
|
/// A fake rather than the real TPM-backed store, and not for speed: the real one prompts for a Windows
|
|
/// consent dialog on every save and every load, so a suite using it would block forever waiting for
|
|
/// somebody to enter a PIN. What the shell has to get right is which buttons appear and what happens when
|
|
/// one is pressed, and that is exactly what a fake keystore can answer.
|
|
/// </remarks>
|
|
private FakeDeviceKeyStore deviceKeys = null!;
|
|
private MainWindowViewModel shell = null!;
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
// A real directory and a real SQLite file, because the production path is what StartAsync runs and
|
|
// an in-memory database would skip the migration that creates the file.
|
|
directory = Path.Combine(Path.GetTempPath(), $"dodossh-shell-{Guid.CreateVersion7():N}");
|
|
paths = new ClientPaths(directory);
|
|
caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
|
|
|
// The real store, not a stand-in. It is the one the application composes, its lifecycle is this
|
|
// shell's business — opened on unlock, closed on lock — and the trust it records goes into the vault
|
|
// this suite already has, so substituting one would only stop the wiring being tested.
|
|
knownHosts = new VaultKnownHostStore();
|
|
deviceKeys = new FakeDeviceKeyStore();
|
|
|
|
// In-memory assets rather than the application's Avalonia-resource provider, which reads the
|
|
// resource system at construction and needs an initialised toolkit. This is what
|
|
// ITerminalAssetProvider is for; nothing in this suite renders anything.
|
|
//
|
|
// The page carries the same two placeholders the real one does, because FakeRenderer attaches by
|
|
// reading them back out of the served page rather than by being handed the token.
|
|
//
|
|
// The renderer timeout is cut right down for the same reason: the tests that want a renderer attach
|
|
// one themselves, so a connect in a test that does not would wait the shipped fifteen seconds out
|
|
// in full, and that is fifteen seconds of a suite sitting still. A second rather than milliseconds
|
|
// because this bounds FakeRenderer's own wait too — an in-process loopback handshake that has
|
|
// already returned, so the margin is enormous, but not one worth making a loaded machine race for.
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(
|
|
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
|
{
|
|
["/terminal"] = new(
|
|
"text/html; charset=utf-8",
|
|
System.Text.Encoding.UTF8.GetBytes(
|
|
$"<!doctype html><div data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
|
|
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></div>")),
|
|
}),
|
|
ssh,
|
|
TimeProvider.System,
|
|
new TerminalWorkspaceOptions { RendererTimeout = TimeSpan.FromSeconds(1) });
|
|
|
|
// Started, as the application does immediately after composing it. Without the accept loop the
|
|
// page is never served, so nothing could attach a renderer.
|
|
workspace.Start();
|
|
|
|
shell = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
deviceKeys,
|
|
SignInAsync,
|
|
TimeProvider.System,
|
|
ssh,
|
|
CheapProfile,
|
|
ResumeAsync,
|
|
|
|
// ◆ A clipboard, where this fixture used to pass none. Both heads wire one now — the phone's
|
|
// was simply never passed, which made every copy on that head answer "this machine has no
|
|
// clipboard" on a device that plainly has one. A fixture without one modelled the bug rather
|
|
// than the product. The branch for a machine that really has none is still covered, by a shell
|
|
// built without one where it is the thing under test.
|
|
copyToClipboard: text =>
|
|
{
|
|
clipboard.Add(text);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await workspace.DisposeAsync();
|
|
caches.Dispose();
|
|
|
|
if (Directory.Exists(directory))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TheTerminalFontSize_StartsAtTheSizeTheRendererDrawsAt()
|
|
{
|
|
// The page creates panes at its own constant until it is told otherwise. A different number here
|
|
// would show as the terminal resizing itself on every launch, in front of the user.
|
|
shell.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The cap is what stops "larger" arriving at a terminal too narrow to hold a prompt — this resizes the
|
|
/// grid rather than magnifying it, so every step up is columns taken away from the remote. Asserted
|
|
/// through the command rather than the clamp so the disabled state is covered with it: a button that
|
|
/// keeps accepting presses and does nothing reads as the application having stopped responding.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void EnlargingPastTheCap_StopsAndSaysSo()
|
|
{
|
|
for (var i = 0; i < 100; i++)
|
|
{
|
|
shell.EnlargeTerminalFontCommand.Execute(null);
|
|
}
|
|
|
|
shell.TerminalFontSize.ShouldBe(ClientSettings.MaximumTerminalFontSize);
|
|
shell.CanEnlargeTerminalFont.ShouldBeFalse();
|
|
shell.CanShrinkTerminalFont.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void ShrinkingPastTheFloor_StopsAndSaysSo()
|
|
{
|
|
for (var i = 0; i < 100; i++)
|
|
{
|
|
shell.ShrinkTerminalFontCommand.Execute(null);
|
|
}
|
|
|
|
shell.TerminalFontSize.ShouldBe(ClientSettings.MinimumTerminalFontSize);
|
|
shell.CanShrinkTerminalFont.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void ResettingTheTerminalFont_GoesBackToTheDefault()
|
|
{
|
|
shell.EnlargeTerminalFontCommand.Execute(null);
|
|
shell.EnlargeTerminalFontCommand.Execute(null);
|
|
|
|
shell.ResetTerminalFontCommand.Execute(null);
|
|
|
|
shell.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The reason the setting is a file beside the cache rather than a row inside it: this has to be
|
|
/// readable on a launch that never unlocks anything, which is every launch up to the passphrase. A
|
|
/// second shell over the same profile directory is exactly that launch.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ASizeChosenOnce_IsThereOnTheNextLaunch()
|
|
{
|
|
shell.EnlargeTerminalFontCommand.Execute(null);
|
|
shell.EnlargeTerminalFontCommand.Execute(null);
|
|
|
|
var chosen = shell.TerminalFontSize;
|
|
chosen.ShouldBe(ClientSettings.DefaultTerminalFontSize + 2);
|
|
|
|
var relaunched = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
deviceKeys,
|
|
SignInAsync,
|
|
TimeProvider.System,
|
|
ssh,
|
|
CheapProfile,
|
|
ResumeAsync);
|
|
|
|
await using (relaunched.ConfigureAwait(false))
|
|
{
|
|
relaunched.TerminalFontSize.ShouldBe(chosen);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AFreshMachine_AsksForAServer()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.IsNeedingServer.ShouldBeTrue();
|
|
shell.IsOnline.ShouldBeFalse();
|
|
|
|
// The migration ran, so the file exists before anyone has signed in to anything.
|
|
File.Exists(paths.CacheFile).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SigningInToAnUnenrolledAccount_AsksForAPassphrase()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsEnrollment);
|
|
shell.IsOnline.ShouldBeTrue();
|
|
shell.AccountName.ShouldBe("Alice Example");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnUnreachableServer_ReportsAndStaysPut()
|
|
{
|
|
server.SignInFailure = new HttpRequestException("No such host is known.");
|
|
|
|
await shell.StartAsync(Token);
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.StatusMessage.ShouldContain("No such host");
|
|
shell.IsBusy.ShouldBeFalse("a failed command must not leave the window disabled");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnInvalidServerUrl_IsRejectedWithoutTouchingTheNetwork()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
|
|
shell.ServerUrl = "not a url";
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.IsOnline.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Separate from the case above because it is not caught by the same check. <c>Uri.TryCreate</c>
|
|
/// accepts this happily as an absolute URI whose <em>scheme</em> is "localhost" and whose host is
|
|
/// empty, so without an explicit scheme check the mistake surfaces much later as something that reads
|
|
/// like a network fault.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AServerUrlWithNoScheme_SaysSoRatherThanFailingLater()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
|
|
shell.ServerUrl = "localhost:5233";
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.IsOnline.ShouldBeFalse();
|
|
shell.StatusMessage.ShouldContain("http://");
|
|
signInAttempts.ShouldBe(0, "a malformed URL must not open a browser");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The shipped default is a value a user is invited to accept unread — it is what the button under it
|
|
/// will actually contact — so it is worth one assertion. It has twice been an address nothing was
|
|
/// listening on: <c>https://localhost:7217</c>, the API's second launch profile, and then the first
|
|
/// profile's <c>http://localhost:5233</c> in a release build, which is a machine an installed
|
|
/// application is not running.
|
|
/// </para>
|
|
/// <para>
|
|
/// The address is written out rather than compared against the constant itself. Asserting a constant
|
|
/// against itself would pass however it were edited, and the whole point of this test is that no
|
|
/// build ships a developer's loopback address.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public void TheDefaultServerUrl_IsTheHostedDeployment_InEveryBuild()
|
|
{
|
|
shell.ServerUrl.ShouldBe("https://ssh.dodotech.cloud");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("short", "short")]
|
|
[InlineData("a sufficiently long passphrase", "a different one")]
|
|
public async Task AWeakOrMismatchedPassphrase_DoesNotEnroll(string entered, string confirmation)
|
|
{
|
|
await SignedInAsync();
|
|
|
|
shell.Passphrase = entered;
|
|
shell.ConfirmPassphrase = confirmation;
|
|
|
|
await shell.EnrollCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsEnrollment);
|
|
server.EnrollmentCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheRecoveryCodeScreen_CannotBeSkipped()
|
|
{
|
|
// The only moment the code exists. Losing it along with the passphrase means the vault is
|
|
// unrecoverable and there is no server-side reset, so this is the one screen that has to insist.
|
|
await EnrolledAsync();
|
|
|
|
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
|
|
shell.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
|
|
|
|
// Trying to continue without confirming gets nowhere.
|
|
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
|
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
|
|
shell.RecoveryCode.ShouldNotBeNull();
|
|
|
|
shell.RecoveryCodeWrittenDown = true;
|
|
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
|
|
// And it is dropped from memory, not merely hidden. It was never persisted; keeping it in a view
|
|
// model for the rest of the session would undo that.
|
|
shell.RecoveryCode.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AWrongPassphrase_KeepsTheVaultLocked()
|
|
{
|
|
await ReadyToUnlockAsync();
|
|
|
|
shell.Passphrase = "not the passphrase";
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
shell.Vault.ShouldBeNull();
|
|
shell.StatusMessage.ShouldContain("did not open");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnlockingOpensTheVault()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked);
|
|
shell.Vault.ShouldNotBeNull();
|
|
shell.Vault.VaultName.ShouldBe("Personal");
|
|
|
|
// Cleared once used, so it is not sitting in a bound property for the rest of the session.
|
|
shell.Passphrase.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARestartUnlocksWithNoNetworkAtAll()
|
|
{
|
|
// The property the whole storage layer exists for, from the shell's point of view. The second
|
|
// shell is given a sign-in delegate that fails if called.
|
|
await EnrolledAndConfirmedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
new UnavailableDeviceKeyStore(),
|
|
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
|
|
TimeProvider.System,
|
|
ssh,
|
|
CheapProfile);
|
|
|
|
await using var _ = offline.ConfigureAwait(false);
|
|
|
|
await offline.StartAsync(Token);
|
|
|
|
offline.State.ShouldBe(ShellState.Locked);
|
|
offline.AccountName.ShouldBe("Alice Example");
|
|
offline.IsOnline.ShouldBeFalse();
|
|
|
|
offline.Passphrase = Passphrase;
|
|
await offline.UnlockCommand.ExecuteAsync(null);
|
|
|
|
offline.State.ShouldBe(ShellState.Unlocked);
|
|
offline.Vault.ShouldNotBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// This used to assert the opposite of its last two lines — that a save queued the change and pushed
|
|
/// nothing until Sync was pressed. Saving now pushes, so the assertion had to move rather than be
|
|
/// deleted: the local-first guarantee it was really protecting is that the list updates without a
|
|
/// server, and that is still covered by the offline test below.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AddingAHost_ShowsItImmediatelyAndPushesIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.IsEditing.ShouldBeTrue();
|
|
|
|
vault.EditorLabel = "prod-db";
|
|
vault.EditorHostname = "db.internal";
|
|
vault.EditorUsername = "deploy";
|
|
vault.EditorPort = 2222;
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.IsEditing.ShouldBeFalse();
|
|
|
|
var row = vault.Hosts.ShouldHaveSingleItem();
|
|
row.Label.ShouldBe("prod-db");
|
|
row.Address.ShouldBe("deploy@db.internal:2222");
|
|
|
|
row.HasUnsyncedChanges.ShouldBeFalse("saving pushes, so nothing should still be pending");
|
|
row.Badge.ShouldBeEmpty();
|
|
|
|
vault.PendingChanges.ShouldBe(0);
|
|
server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnAutomaticPass_SaysNothingWhenThereIsNothingToDo()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.Status = "Reading something the user cares about.";
|
|
|
|
await vault.AutoSyncAsync(Token);
|
|
|
|
vault.Status.ShouldBe(
|
|
"Reading something the user cares about.",
|
|
"a background pass with no changes must not repaint the status line");
|
|
|
|
vault.IsBusy.ShouldBeFalse("a background pass must never raise the busy flag");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The queue has to be arranged by failing the automatic push first. Written the obvious way — add a
|
|
/// host, then call the pass — this test proved nothing at all: saving pushes, so there was no pending
|
|
/// change left and the count was unchanged whether the guard existed or not. It passed with the guard
|
|
/// deleted, which is the only reason it was noticed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnAutomaticPass_YieldsWhileACommandIsRunning()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
|
await AddHostAsync(vault, "prod-db");
|
|
server.SyncFailure = null;
|
|
|
|
vault.PendingChanges.ShouldBe(1, "there must be something to push for this to mean anything");
|
|
|
|
var pushesBefore = server.PushCount;
|
|
|
|
// Standing in for a command in flight. A pass that pushed here would be competing with whatever
|
|
// the user is doing for the same session and the same cache.
|
|
vault.IsBusy = true;
|
|
|
|
await vault.AutoSyncAsync(Token);
|
|
|
|
server.PushCount.ShouldBe(pushesBefore, "the pass should have been skipped, not queued");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The behaviour a background loop lives or dies by. A pass runs every minute; one that reported a
|
|
/// transient server error would replace whatever the user was reading, once a minute, indefinitely.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnAutomaticPassThatFails_LeavesTheStatusAlone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.Status = "Reading something the user cares about.";
|
|
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
|
|
|
await vault.AutoSyncAsync(Token);
|
|
|
|
vault.Status.ShouldBe("Reading something the user cares about.");
|
|
vault.IsBusy.ShouldBeFalse();
|
|
|
|
// And pressing Sync still reports the real reason, so the failure is quiet rather than hidden.
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
vault.Status.ShouldContain("bad day");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The whole point of the push channel: a pass that did not wait for the minute.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The timing is what makes this an assertion rather than a hope. The background timer is a full
|
|
/// minute and the wait below gives up in ten seconds, so a pull that arrives can only have been
|
|
/// caused by the notice — there is no interval at which the timer could have produced it.
|
|
/// </para>
|
|
/// <para>
|
|
/// The vault id in the notice is arbitrary, and deliberately so: a pass synchronises every vault
|
|
/// this session can reach, so the loop reads the notice as "there is something to fetch" and never
|
|
/// as "fetch this one". A test that seeded a real id would imply a targeting this does not do.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task APushedNotice_SynchronisesWithoutWaitingForTheTimer()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
// The unlock starts the loop, whose first act is a pass; waited out so the count below is a
|
|
// baseline rather than a race with it.
|
|
await EventuallyAsync(
|
|
() => server.PullCount > 0,
|
|
"the pass on open should have run");
|
|
|
|
var before = server.PullCount;
|
|
|
|
server.Notices.Push(Guid.CreateVersion7());
|
|
|
|
await EventuallyAsync(
|
|
() => server.PullCount > before,
|
|
"a notice should have woken the loop long before the one-minute timer");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The half that is easy to get wrong. The loop selects between two waits, and both have to survive
|
|
/// losing: <c>PeriodicTimer</c> throws if a second wait is started while one is outstanding, and an
|
|
/// abandoned channel read stays registered and swallows the next notice written. Either defect
|
|
/// leaves the first notice working and every one after it silently lost, which is why one notice is
|
|
/// not enough to prove this.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task NoticesKeepWakingTheLoop_NotJustTheFirst()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
await EventuallyAsync(() => server.PullCount > 0, "the pass on open should have run");
|
|
|
|
for (var round = 1; round <= 3; round++)
|
|
{
|
|
var before = server.PullCount;
|
|
|
|
server.Notices.Push(Guid.CreateVersion7());
|
|
|
|
await EventuallyAsync(
|
|
() => server.PullCount > before,
|
|
$"notice {round} should have woken the loop as the first one did");
|
|
}
|
|
}
|
|
|
|
/// <summary>Waits for something a background loop is expected to do, or fails saying what.</summary>
|
|
/// <remarks>
|
|
/// Polled rather than signalled because the thing under test is a loop nobody hands a completion
|
|
/// source to. The bound is generous — this is not measuring latency, only proving that the timer
|
|
/// cannot be what caused the result.
|
|
/// </remarks>
|
|
private static async Task EventuallyAsync(Func<bool> condition, string because)
|
|
{
|
|
var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);
|
|
|
|
while (TimeProvider.System.GetUtcNow() < deadline)
|
|
{
|
|
if (condition())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
|
|
}
|
|
|
|
throw new ShouldAssertException(because);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The page's own <c>term.focus()</c> focuses the textarea inside the document, which does nothing
|
|
/// while the window's keyboard focus is still on the Connect button — so the first keystrokes of a
|
|
/// session went to the shell's UI rather than the remote shell, and the terminal had to be clicked
|
|
/// first. The view hands the control focus when this fires; see <c>NativeKeyboardFocus</c> for why an
|
|
/// ordinary <c>Focus()</c> call is enough in that direction and not in the other.
|
|
/// </para>
|
|
/// <para>
|
|
/// What this covers is the plumbing that carries the fix: that the raise is on the success path and
|
|
/// happens once per session, and that the shell forwards it. Deleting the raise outright is already a
|
|
/// build error — the event would be unused, and warnings are errors — but moving it, which is the
|
|
/// likelier mistake, is not. It does <em>not</em> cover the focus call itself: that needs a native
|
|
/// window, and headless Avalonia has none, which is exactly why this class of defect has escaped
|
|
/// tests here before. Measured separately in a harness; see docs/platform-flags.md.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingAsksTheViewToFocusTheTerminal()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
var requests = 0;
|
|
shell.TerminalSessionOpened += (_, _) => requests++;
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
|
requests.ShouldBe(1);
|
|
|
|
// Again, on a second session. This is why it is an event and not a bound flag: a boolean that was
|
|
// already true would not move focus to the terminal the user just opened.
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
requests.ShouldBe(2);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Focus must not be taken on a failure. A host-key prompt needs the keyboard on the prompt's own
|
|
/// buttons, and taking it into a terminal that has no session would strand the decision.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AFailedConnect_DoesNotAskForTheTerminalToBeFocused()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Failure = new SshHostKeyUnknownException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
|
|
|
|
var requests = 0;
|
|
shell.TerminalSessionOpened += (_, _) => requests++;
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
vault.HasPendingHostKey.ShouldBeTrue();
|
|
requests.ShouldBe(0);
|
|
}
|
|
|
|
// ---- Which surface is showing ----
|
|
//
|
|
// The tab strip is visible from every screen, so a terminal and a page are two things the window can be
|
|
// showing rather than one screen among five. These fix that state machine. None of them can see the
|
|
// WebView itself — headless Avalonia has no native window — but every transition below is decided here,
|
|
// in ordinary objects, which is why they are worth having.
|
|
|
|
/// <remarks>
|
|
/// The point of the whole rework, stated as one assertion: a terminal opened from somewhere other than
|
|
/// the hosts screen shows, and the screen underneath it does not move. Moving it would make opening a
|
|
/// terminal a way to lose your place in a transfer that is still running.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OpeningATerminalFromAnotherScreen_ShowsItAndLeavesTheScreenWhereItWas()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Surface.ShouldBe(ShellSurface.Terminal);
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
shell.IsShowingPages.ShouldBeFalse();
|
|
|
|
shell.Screen.ShouldBe(ShellScreen.Transfers, "the page underneath is what closing the tab returns to");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ANavRailClick_HidesTheTerminalAndKeepsTheTab()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
|
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
shell.IsShowingPages.ShouldBeTrue();
|
|
shell.IsKeychainShowing.ShouldBeTrue();
|
|
|
|
// The session is untouched. Navigating away from a terminal is not a way to end one; only closing
|
|
// its tab is.
|
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
tab.IsLive.ShouldBeTrue();
|
|
shell.SelectedTab.ShouldBe(tab);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClickingATab_BringsTheTerminalBackFromAPage()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
|
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
shell.Screen.ShouldBe(ShellScreen.Preferences);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The phone's bottom bar names the terminal beside the pages, so the surface needs a command of its
|
|
/// own — the desktop only ever reaches it implicitly, by opening a session or clicking a tab.
|
|
/// </para>
|
|
/// <para>
|
|
/// Tested here rather than in the Android head because it is shared state-machine behaviour, and
|
|
/// because nothing in this repository can run a test on a phone.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ShowingTheTerminal_SwitchesSurfaceWithoutChangingTheScreen()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
|
|
shell.ShowTerminalCommand.Execute(null);
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
shell.IsShowingPages.ShouldBeFalse();
|
|
|
|
// The page underneath is remembered, not reset. Going to the terminal and back is navigation, and
|
|
// navigation that forgets where you were is how a four-button bar becomes annoying.
|
|
shell.Screen.ShouldBe(ShellScreen.Keychain);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The one connection this application makes to a machine that is not in the keychain. What is worth
|
|
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
|
|
/// the port all come out of the one box.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AManualTarget_IsDialledExactlyAsItWasTyped()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ManualTarget = " deploy@build.internal:2222 ";
|
|
vault.ManualPassword = "hunter2";
|
|
|
|
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
|
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
|
request.Host.ShouldBe("build.internal");
|
|
request.Port.ShouldBe(2222);
|
|
request.Username.ShouldBe("deploy");
|
|
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AManualTargetWithNoPort_TakesTwentyTwo()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ManualTarget = "root@box";
|
|
vault.ManualPassword = "hunter2";
|
|
|
|
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
|
|
/// is never a login on anything, so the guess would fail at the remote as "authentication failed"
|
|
/// rather than here as a sentence about the box that was typed into.
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData("", "Type a machine")]
|
|
[InlineData("build.internal", "Say who to log in as")]
|
|
[InlineData("deploy@", "Say who to log in as")]
|
|
[InlineData("@build.internal", "Say who to log in as")]
|
|
[InlineData("deploy@build.internal:70000", "between 1 and 65535")]
|
|
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
|
|
[InlineData("deploy@[fe80::1]", "bracketed IPv6")]
|
|
public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled(
|
|
string typed,
|
|
string because)
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ManualTarget = typed;
|
|
vault.ManualPassword = "hunter2";
|
|
|
|
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
|
|
vault.ManualStatus.ShouldContain(because);
|
|
ssh.Requests.ShouldBeEmpty();
|
|
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A password is the only thing this path can authenticate with, so an empty one is refused here rather
|
|
/// than sent. Offering the keychain's keys would be a second binding resolution beside the connect
|
|
/// path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only copy of.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ManualTarget = "root@box";
|
|
|
|
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
|
|
vault.ManualStatus.ShouldContain("password");
|
|
ssh.Requests.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
|
|
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
|
|
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
|
|
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
|
|
/// the assertion would still catch it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TrustingAHostKey_RetriesTheAttemptThatRaisedItRatherThanTheSelectedHost()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.SelectedHost.ShouldNotBeNull();
|
|
|
|
ssh.Failure = new SshHostKeyUnknownException(
|
|
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
|
|
|
vault.ManualTarget = "deploy@build.internal:2222";
|
|
vault.ManualPassword = "hunter2";
|
|
|
|
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
|
|
vault.HasPendingHostKey.ShouldBeTrue();
|
|
|
|
ssh.Failure = null;
|
|
|
|
await vault.TrustHostKeyCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.Count.ShouldBe(2);
|
|
ssh.Requests[1].Host.ShouldBe("build.internal", "the retry is the attempt that asked the question");
|
|
ssh.Requests[1].Port.ShouldBe(2222);
|
|
ssh.Requests[1].Username.ShouldBe("deploy");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The
|
|
/// keychain half goes to the host's own connect bar rather than connecting from here, because that bar
|
|
/// is where its key, its password box and its refusals already live.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
var host = vault.Hosts[0];
|
|
vault.SelectedHost = null;
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
|
|
shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId));
|
|
|
|
shell.IsHostsShowing.ShouldBeTrue();
|
|
vault.SelectedHost.ShouldBe(host);
|
|
vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half. The log stored what was actually dialled, which is the grammar the manual box takes,
|
|
/// so it goes straight back in — without the password, which was never stored and whose absence is the
|
|
/// point of that path rather than a gap in it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
// Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the
|
|
// screen the application happens to open on.
|
|
shell.ShowTerminalCommand.Execute(null);
|
|
|
|
shell.ConnectToRecentCommand.Execute(
|
|
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
|
|
|
|
vault.ManualTarget.ShouldBe("deploy@build.internal:2222");
|
|
vault.ManualPassword.ShouldBeEmpty();
|
|
shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A host deleted since it was connected to. The machine is still there and the keychain no longer knows
|
|
/// about it, so the address is the honest answer rather than a tap that does nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
shell.ShowTerminalCommand.Execute(null);
|
|
|
|
shell.ConnectToRecentCommand.Execute(
|
|
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
|
|
|
|
vault.ManualTarget.ShouldBe("root@prod-db:22");
|
|
shell.IsTerminalSurface.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>One row of the connection log, built by hand.</summary>
|
|
/// <remarks>
|
|
/// Built rather than connected-and-closed, because what these three tests are about is which of the two
|
|
/// branches a row takes — and driving that through a real connection, a real close and the recorder's own
|
|
/// queue would test the recorder instead, which <c>DodoSSH.Client.Session.Tests</c> already does.
|
|
/// </remarks>
|
|
private static ConnectionLogRowViewModel Recent(string label, string address, Guid? hostId) =>
|
|
new(
|
|
new VaultItem<ConnectionLogSecret>(
|
|
Guid.CreateVersion7(),
|
|
new ConnectionLogSecret
|
|
{
|
|
HostLabel = label,
|
|
Address = address,
|
|
HostId = hostId,
|
|
StartedAt = DateTimeOffset.UnixEpoch,
|
|
DeviceName = "a phone",
|
|
},
|
|
Version: 1,
|
|
HasUnsyncedChanges: false,
|
|
IsBlocked: false,
|
|
IsReadOnly: false),
|
|
isLive: false);
|
|
|
|
// ---- Last connected ----
|
|
//
|
|
// The card's ago-text: VaultViewModel.DescribeElapsed is the pure word-choice, RefreshLastConnectedAsync
|
|
// is the log read, and the two triggers below — the hosts screen coming back on screen and a session
|
|
// ending on its own — are decision 4's whole "when" in HANDOFF-hosts-v5.md.
|
|
|
|
/// <remarks>
|
|
/// Every boundary the wording changes at: fifty-nine seconds is still "just now" and sixty is the first
|
|
/// "1 min ago"; the same shape repeats crossing into hours and into days. A pure function of the gap, so
|
|
/// none of this needs a fake clock — see the remark on <c>VaultViewModel.DescribeElapsed</c> itself.
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData(0, "just now")]
|
|
[InlineData(59, "just now")]
|
|
[InlineData(60, "1 min ago")]
|
|
[InlineData(150, "2 min ago")]
|
|
[InlineData(3599, "59 min ago")]
|
|
[InlineData(3600, "1 hr ago")]
|
|
[InlineData(7200, "2 hr ago")]
|
|
[InlineData(86399, "23 hr ago")]
|
|
[InlineData(86400, "1 day ago")]
|
|
[InlineData(172800, "2 days ago")]
|
|
public void DescribeElapsed_MatchesTheWordACardShouldShowAtEachBoundary(int seconds, string expected) =>
|
|
VaultViewModel.DescribeElapsed(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
|
|
|
|
/// <remarks>
|
|
/// The hosts screen's own activation — one of the two moments <c>VaultViewModel.RefreshLastConnectedAsync</c>
|
|
/// is read on. <c>ReadyToConnectAsync</c> already visited this screen once, before the log held anything
|
|
/// worth reading, so the log is seeded only afterwards and the screen is left and returned to — the
|
|
/// transition <c>MainWindowViewModel.UpdateLastConnectedVisibility</c> actually keys off, rather than the
|
|
/// level, which fired already.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ReturningToTheHostsScreen_FillsInLastConnectedFromTheLog()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
var host = vault.Hosts[0];
|
|
|
|
await vault.Session.ConnectionLog.CreateAsync(
|
|
vault.Session.ActiveVaultId,
|
|
new ConnectionLogSecret
|
|
{
|
|
HostLabel = host.Label,
|
|
Address = host.Address,
|
|
HostId = host.EntityId,
|
|
StartedAt = TimeProvider.System.GetUtcNow().AddDays(-3),
|
|
DeviceName = "a workstation",
|
|
},
|
|
Token);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
|
|
|
await EventuallyAsync(
|
|
() => host.LastConnectedText.Length > 0,
|
|
"activating the hosts screen should have read the log");
|
|
|
|
host.LastConnectedText.ShouldBe("3 days ago");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A host the log has never named. Empty rather than a dash or the word "never" — see
|
|
/// <c>HostRowViewModel.LastConnectedText</c>'s own remarks: a host nobody has connected to and a host
|
|
/// whose log simply has not been read yet look identical from this row, and neither is a claim it can
|
|
/// make on its own.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AHostTheLogHasNeverNamed_ShowsNoAgoText()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
var host = vault.Hosts[0];
|
|
|
|
await vault.RefreshLastConnectedAsync(Token);
|
|
|
|
host.LastConnectedText.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Decision 4's second half: a host with a session open right now shows the green dot instead of an
|
|
/// ago-text, even with an entry in the log to offer. Printing both would answer the same question twice,
|
|
/// and the older answer is the one likeliest to be misread as current.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AConnectedHost_ShowsNoAgoTextEvenWithAnEntryInTheLog()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
var host = vault.Hosts[0];
|
|
|
|
await vault.Session.ConnectionLog.CreateAsync(
|
|
vault.Session.ActiveVaultId,
|
|
new ConnectionLogSecret
|
|
{
|
|
HostLabel = host.Label,
|
|
Address = host.Address,
|
|
HostId = host.EntityId,
|
|
StartedAt = TimeProvider.System.GetUtcNow().AddMinutes(-5),
|
|
DeviceName = "a workstation",
|
|
},
|
|
Token);
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
host.IsConnected.ShouldBeTrue();
|
|
|
|
await vault.RefreshLastConnectedAsync(Token);
|
|
|
|
host.LastConnectedText.ShouldBeEmpty("the dot already says this host is open right now");
|
|
}
|
|
|
|
// The other trigger — a session ending on its own — has no test here. It runs inside
|
|
// MainWindowViewModel.OnWorkspaceSessionEnded's existing Dispatcher.UIThread.Post, the same one
|
|
// RefreshConnectedHosts() already ran inside before this wave touched the method, and this suite has no
|
|
// window pumping that dispatcher — see TransferQueueingTests's own remark on why it built a posted-action
|
|
// queue rather than depend on Dispatcher.UIThread at all. A test posted there would time out proving
|
|
// nothing about the one line this wave added, since the untestable half is wiring this wave did not
|
|
// write. The call itself — `_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);`, placed
|
|
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
|
|
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
|
|
|
|
/// <remarks>
|
|
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
|
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
|
/// was raised from is part of it — see <c>MainWindowViewModel.IsTerminalShowing</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheConnectSheet_HidesTheRendererAndLeavesTheSurfaceUnderIt()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.OpenConnectSheetCommand.Execute(null);
|
|
|
|
shell.IsConnectSheetOpen.ShouldBeTrue();
|
|
shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle");
|
|
shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface");
|
|
|
|
shell.CloseConnectSheetCommand.Execute(null);
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The flag holds the renderer blank, so one set while a page was showing would be a sheet nobody can
|
|
/// see keeping a terminal hidden that nothing would put back.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheConnectSheet_RefusesToOpenOverAPage()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
|
|
|
shell.OpenConnectSheetCommand.Execute(null);
|
|
|
|
shell.IsConnectSheetOpen.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Every entry on the menu navigates, and none of them closes the sheet itself: leaving the terminal
|
|
/// surface is what lowers it. That is the guarantee worth a test — it is what makes routes nobody wrote
|
|
/// the sheet for, like closing the last tab or locking, safe.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task LeavingTheTerminal_LowersTheConnectSheetHoweverItIsLeft()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
// The menu's own second entry: one screen, over one view model, with the kind of remote chosen by
|
|
// the thing that navigates.
|
|
shell.OpenConnectSheetCommand.Execute(null);
|
|
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
|
|
|
|
shell.IsConnectSheetOpen.ShouldBeFalse();
|
|
shell.IsBucketsShowing.ShouldBeTrue();
|
|
|
|
// And a route the sheet was never wired to: back to the terminal, open it, then end the only shell
|
|
// there is.
|
|
shell.ShowTerminalCommand.Execute(null);
|
|
shell.OpenConnectSheetCommand.Execute(null);
|
|
shell.IsConnectSheetOpen.ShouldBeTrue();
|
|
|
|
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
|
|
|
shell.IsConnectSheetOpen.ShouldBeFalse("closing the last tab returns the surface to a page");
|
|
shell.IsShowingPages.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A visible WebView with no pane in it reads as the application having broken, so this is the one
|
|
/// transition that moves the surface back on its own.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClosingTheLastTab_ReturnsToThePageThatWasShowing()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
|
|
|
shell.Tabs.ShouldBeEmpty();
|
|
shell.SelectedTab.ShouldBeNull();
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
shell.IsTransfersShowing.ShouldBeTrue("the page that was showing when the terminal opened");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClosingOneOfTwoTabs_KeepsTheTerminalShowing()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Tabs.Count.ShouldBe(2);
|
|
|
|
// The selected one, which is the second. The neighbour takes its place and the terminal stays.
|
|
await shell.CloseTabCommand.ExecuteAsync(shell.SelectedTab!);
|
|
|
|
shell.SelectedTab.ShouldBe(shell.Tabs.ShouldHaveSingleItem());
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClosingATabThatIsNotSelected_ChangesNothingAboutTheSurface()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
var first = shell.Tabs[0];
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
var second = shell.Tabs[1];
|
|
|
|
await shell.CloseTabCommand.ExecuteAsync(first);
|
|
|
|
shell.SelectedTab.ShouldBe(second);
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
}
|
|
|
|
// ---- Connecting, while it is still happening ----
|
|
//
|
|
// A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
|
|
// in which a tab exists and its session does not. Everything below is about that stretch: what the strip
|
|
// shows, what the window draws in the terminal's rectangle, and what happens to the tab when the
|
|
// connection answers — or does not.
|
|
|
|
/// <remarks>
|
|
/// The point of the whole thing, stated as one assertion: the tab is in the strip before the connection
|
|
/// has answered, and the vault is not busy while it waits. A user who asked for a machine that is asleep
|
|
/// used to get a status line and a window that did nothing for as long as the timeout took.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Connecting_ShowsATabImmediatelyAndLeavesTheVaultUsable()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Gate = new TaskCompletionSource();
|
|
var connecting = vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
tab.IsConnecting.ShouldBeTrue();
|
|
tab.HasSession.ShouldBeFalse();
|
|
tab.Label.ShouldBe("prod-db");
|
|
tab.Address.ShouldBe("deploy@db.internal:22", "named for what is being dialled, not for what answered");
|
|
|
|
// The card, not the renderer. They share one rectangle and there is no pane to put in it yet.
|
|
shell.SelectedTab.ShouldBe(tab);
|
|
shell.IsConnectingShowing.ShouldBeTrue();
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
|
|
// The gate this command does not hold. Everything else on this screen still works, which is the
|
|
// difference between waiting and being stuck.
|
|
vault.IsBusy.ShouldBeFalse();
|
|
vault.Hosts.ShouldNotBeEmpty();
|
|
|
|
ssh.Gate.SetResult();
|
|
await connecting;
|
|
|
|
tab.HasSession.ShouldBeTrue();
|
|
tab.IsLive.ShouldBeTrue();
|
|
tab.Status.ShouldBeEmpty("the pane speaks for itself from here on");
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
shell.IsConnectingShowing.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
|
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
|
/// and it stays until it is closed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARefusedConnection_LeavesATabCarryingTheReason()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Failure = new InvalidOperationException("No route to host.");
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
tab.IsFailed.ShouldBeTrue();
|
|
tab.IsLive.ShouldBeFalse();
|
|
tab.Status.ShouldBe("No route to host.");
|
|
|
|
shell.IsConnectingShowing.ShouldBeTrue("the card is where the reason is drawn");
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
|
|
// Closed like any other tab, and without asking the workspace to end a session that never existed.
|
|
await shell.CloseTabCommand.ExecuteAsync(tab);
|
|
|
|
shell.Tabs.ShouldBeEmpty();
|
|
shell.IsHostsShowing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other kind of not-connecting. An unknown host key is a question rather than a failure, so the tab
|
|
/// goes — a tab saying the connection failed would be competing with the decision that is about to resume
|
|
/// it — and the window stays where it was, because the decision is drawn over whatever that is.
|
|
/// </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();
|
|
|
|
// It used to assert IsHostsShowing here, because the prompt was a banner on that screen and this
|
|
// handler navigated to it. The screen is untouched now and the card is over it instead.
|
|
shell.Screen.ShouldBe(ShellScreen.Transfers);
|
|
shell.IsHostKeyDecisionShowing.ShouldBeTrue("and it has to be reachable from wherever the user is");
|
|
|
|
// 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();
|
|
shell.IsHostKeyDecisionShowing.ShouldBeFalse("the decision goes with the answer");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Refusing, and the state it leaves behind. Both halves have to clear: the changed-key refusal is drawn
|
|
/// over the surface on both heads and the phone's is an opaque full-screen panel, so a dismissal that left
|
|
/// the flag set would leave that panel up over every screen the user went to next — including the host
|
|
/// editor it tells them to open. See VaultViewModel.RejectHostKey, which used to clear only the other one.
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task RefusingAHostKeyDecision_TakesItOffTheScreen(bool firstContact)
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Failure = firstContact
|
|
? new SshHostKeyUnknownException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"))
|
|
: new SshHostKeyMismatchException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
|
|
"SHA256:the-pinned-key");
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.IsHostKeyDecisionShowing.ShouldBeTrue(shell.StatusMessage);
|
|
|
|
vault.RejectHostKeyCommand.Execute(null);
|
|
|
|
vault.HasPendingHostKey.ShouldBeFalse();
|
|
vault.HasHostKeyMismatch.ShouldBeFalse();
|
|
shell.IsHostKeyDecisionShowing.ShouldBeFalse();
|
|
|
|
// Nothing was pinned either way, so the machine is still a first contact next time.
|
|
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one thing about the card that nothing else here can see, and the whole arrangement rests on it: the
|
|
/// question belongs to the vault and the flag that draws the card — and collapses the terminal under it —
|
|
/// belongs to the shell, so the shell has to re-raise it. Without that the card would never appear and,
|
|
/// worse, never go away.
|
|
/// </para>
|
|
/// <para>
|
|
/// Nothing else in this suite watches <c>PropertyChanged</c>, and this is why it is worth being the first:
|
|
/// every other assertion about the flag reads it directly, and a direct read passes with the subscription
|
|
/// deleted. The departure is the half that matters — <c>OnVaultConnectionFailed</c> raises the state
|
|
/// itself as the tab goes, so an arrival is announced twice over, and answering the question is announced
|
|
/// only from the vault's own notification.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
var announcements = 0;
|
|
shell.PropertyChanged += OnShellPropertyChanged;
|
|
|
|
try
|
|
{
|
|
ssh.Failure = new SshHostKeyUnknownException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
announcements.ShouldBeGreaterThan(0, "the card has to be told to appear");
|
|
|
|
announcements = 0;
|
|
vault.RejectHostKeyCommand.Execute(null);
|
|
|
|
announcements.ShouldBeGreaterThan(0, "and to go, which is the half a stale flag would trap");
|
|
}
|
|
finally
|
|
{
|
|
shell.PropertyChanged -= OnShellPropertyChanged;
|
|
}
|
|
|
|
void OnShellPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (string.Equals(
|
|
e.PropertyName,
|
|
nameof(MainWindowViewModel.IsHostKeyDecisionShowing),
|
|
StringComparison.Ordinal))
|
|
{
|
|
announcements++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Giving up on a connection that is still in flight. The tab goes at once — that is what the button
|
|
/// promises — and the handshake that finishes afterwards is adopted rather than dropped, because a shell
|
|
/// running with nothing in the window naming it is worse than a tab that comes back.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClosingATabThatIsStillConnecting_TakesItAwayAndKeepsWhateverArrives()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Gate = new TaskCompletionSource();
|
|
var connecting = vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
|
|
|
shell.Tabs.ShouldBeEmpty();
|
|
shell.IsHostsShowing.ShouldBeTrue();
|
|
|
|
ssh.Gate.SetResult();
|
|
await connecting;
|
|
|
|
shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue("the session is real, so it gets a tab");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Two at once, which is the other thing not holding the vault made possible — and the reason an attempt
|
|
/// carries an id rather than being found by the host's name.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TwoConnectionsCanBeInFlightAtOnce()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
await AddHostAsync(vault, "stage-web");
|
|
|
|
ssh.Gate = new TaskCompletionSource();
|
|
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
var first = vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = Host(vault, "stage-web");
|
|
var second = vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Tabs.Select(tab => tab.Label).ShouldBe(["prod-db", "stage-web"]);
|
|
shell.Tabs.ShouldAllBe(tab => tab.IsConnecting);
|
|
|
|
ssh.Gate.SetResult();
|
|
await first;
|
|
await second;
|
|
|
|
shell.Tabs.ShouldAllBe(tab => tab.HasSession);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Locking does not end a handshake any more than it ends a shell, and the tab standing in for one is
|
|
/// shell state that survives a lock. So the answer still has to arrive somewhere: without it the tab
|
|
/// would say "connecting…" for ever and the session it opened would have nothing naming it, and so no
|
|
/// way to be closed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AConnectionInFlightWhenTheVaultLocks_StillLandsInItsTab()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Gate = new TaskCompletionSource();
|
|
var connecting = vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(tab, "tabs outlive the vault that opened them");
|
|
|
|
ssh.Gate.SetResult();
|
|
await connecting;
|
|
|
|
tab.HasSession.ShouldBeTrue();
|
|
tab.IsLive.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A tab is marked by whether its terminal is the thing on screen, not by whether it is the selected
|
|
/// one — the selection survives navigating away, which is what makes the strip a way back rather than a
|
|
/// way to lose a shell. Two "you are here" marks at once is one too many, and the rail's own entries
|
|
/// already make the same distinction.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ATabIsMarkedOnlyWhileItsTerminalIsShowing()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Gate = new TaskCompletionSource();
|
|
var connecting = vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
tab.IsShowing.ShouldBeTrue("the connecting card is what the window is showing");
|
|
|
|
// Navigating away during the connection, which is the case this most exists for: the connection goes
|
|
// on, the tab stays selected, and nothing in the strip claims to be on screen.
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
|
|
|
tab.IsShowing.ShouldBeFalse();
|
|
tab.IsSelected.ShouldBeTrue("navigating away is not deselecting");
|
|
|
|
ssh.Gate.SetResult();
|
|
await connecting;
|
|
|
|
tab.IsShowing.ShouldBeFalse("a connection that finishes while you are elsewhere does not grab the window");
|
|
|
|
shell.SelectTabCommand.Execute(tab);
|
|
|
|
tab.IsShowing.ShouldBeTrue();
|
|
|
|
// And the palette, which draws over the same rectangle.
|
|
shell.ToggleSearchCommand.Execute(null);
|
|
tab.IsShowing.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A shell outlives a lock, so there can be a selected tab while the unlock card is up. The card and the
|
|
/// terminal share a rectangle, and the card is the one that has to win.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Locking_HidesTheTerminalWhateverTheSurfaceWas()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue("locking does not end a session");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnUnlock_LandsOnAPageEvenWithATabStillOpen()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
|
|
shell.IsHostsShowing.ShouldBeTrue();
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ThePalette_HidesTheTerminalAndClosingItBringsItBack()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.ToggleSearchCommand.Execute(null);
|
|
|
|
shell.IsSearching.ShouldBeTrue();
|
|
shell.IsTerminalShowing.ShouldBeFalse("the palette draws over the terminal's rectangle");
|
|
|
|
shell.CloseSearchCommand.Execute(null);
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a
|
|
/// terminal fills the window would point at a screen that is not showing — and the selected tab already
|
|
/// carries that mark, in the strip.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheNavRailLightsExactlyOneEntryOnAPage_AndNoneOnATerminal()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
LitEntries().ShouldBe(1);
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
LitEntries().ShouldBe(0);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
|
LitEntries().ShouldBe(1);
|
|
shell.IsKeychainShowing.ShouldBeTrue();
|
|
|
|
int LitEntries() => new[]
|
|
{
|
|
shell.IsHostsShowing,
|
|
shell.IsTransfersShowing,
|
|
shell.IsKeychainShowing,
|
|
shell.IsVaultsShowing,
|
|
shell.IsPreferencesShowing,
|
|
}.Count(lit => lit);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The palette can be opened from any screen, and this used to be why an unknown host key moved the
|
|
/// window: the prompt was a banner on the hosts screen, so the shell navigated there before letting the
|
|
/// vault raise it, or the connection would have blocked on a question behind whatever the user was
|
|
/// looking at.
|
|
/// </para>
|
|
/// <para>
|
|
/// The decision is drawn over the surface now, on both heads, so nothing moves — and this is the same
|
|
/// test inverted, kept rather than deleted because the requirement it was written for still holds. What
|
|
/// changed is how it is met: the question has to be answerable from where the user is, not the user
|
|
/// taken to where the question is.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingFromThePalette_AsksAboutTheHostKeyWithoutMovingTheWindow()
|
|
{
|
|
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.Screen.ShouldBe(
|
|
ShellScreen.Transfers, "the transfer the user was looking at is still what is underneath");
|
|
|
|
shell.IsHostKeyDecisionShowing.ShouldBeTrue("and the card is over it");
|
|
|
|
// No assertion about the renderer here, deliberately: this attempt's tab was the only one, so it is
|
|
// collapsed for want of a session whatever the occlusion rule says, and a test that cannot fail is
|
|
// worse than no test. That claim belongs where a live terminal is behind the card — see
|
|
// AChangedHostKey_CollapsesTheTerminalItIsRefusedOver.
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The connection with no host, which is the case the old navigation was worst for: it took the box that
|
|
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The
|
|
/// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet —
|
|
/// and what the user comes back to whichever way they answer.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
shell.ShowTerminalCommand.Execute(null);
|
|
|
|
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();
|
|
|
|
shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away");
|
|
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A changed key is the other half of the same control and the reason it collapses the renderer: a second
|
|
/// connection can be refused while a first one is open, and the refusal has to be readable over it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AChangedHostKey_CollapsesTheTerminalItIsRefusedOver()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
// One live session first, so there is something in the rectangle for the refusal to be drawn over.
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.IsTerminalShowing.ShouldBeTrue(shell.StatusMessage);
|
|
|
|
ssh.Failure = new SshHostKeyMismatchException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
|
|
"SHA256:the-pinned-key");
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
vault.HasHostKeyMismatch.ShouldBeTrue();
|
|
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
|
shell.IsTerminalShowing.ShouldBeFalse("the card would otherwise be sliced at the WebView's edge");
|
|
|
|
// And it comes back when the refusal is put away, rather than needing a tab click to restore it.
|
|
vault.RejectHostKeyCommand.Execute(null);
|
|
|
|
shell.IsHostKeyDecisionShowing.ShouldBeFalse();
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TrustingAHostKey_PinsItInTheVaultAndConnects()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Failure = new SshHostKeyUnknownException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:first-contact"));
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
vault.HasPendingHostKey.ShouldBeTrue();
|
|
|
|
// The second connection is the one that succeeds, which is what a trust-and-retry actually is: the
|
|
// handshake is refused, the user decides, and a fresh connection is made with the pin in place.
|
|
ssh.Failure = null;
|
|
|
|
await vault.TrustHostKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.HasPendingHostKey.ShouldBeFalse();
|
|
|
|
// Two connection attempts: the one that was refused and the one the pin allowed. Asserted on the
|
|
// factory rather than on the status line, which the push that follows a trust legitimately repaints.
|
|
ssh.Requests.Count.ShouldBe(2);
|
|
|
|
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token))
|
|
.ShouldBe("SHA256:first-contact");
|
|
|
|
// Pushed as part of trusting, so the next machine to sync is not asked the same question.
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task APinnedHostKey_SurvivesLockingAndUnlocking()
|
|
{
|
|
// The gap this whole item closes, from the shell's point of view: the store is opened on unlock and
|
|
// its contents come out of the vault, so approving a fingerprint is a decision that lasts.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Failure = new SshHostKeyUnknownException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:approved"));
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Failure = null;
|
|
await vault.TrustHostKeyCommand.ExecuteAsync(null);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
// Locked means locked: the pins go with the vault keys, so nothing can answer a host key question
|
|
// while the window is showing an unlock screen.
|
|
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
|
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
|
|
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForgettingAHostKey_ClearsThePinAndTheRefusal()
|
|
{
|
|
// The way back from a rebuilt server, and the reason a mismatch can stay a hard refusal: the user
|
|
// withdraws trust deliberately, from the host's own editor, rather than clicking past a warning.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
ssh.Failure = new SshHostKeyUnknownException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-old-key"));
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Failure = null;
|
|
await vault.TrustHostKeyCommand.ExecuteAsync(null);
|
|
|
|
// The server is rebuilt and offers something else.
|
|
ssh.Failure = new SshHostKeyMismatchException(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
|
|
"SHA256:the-old-key");
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
vault.HasHostKeyMismatch.ShouldBeTrue();
|
|
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.CanForgetHostKey.ShouldBeTrue();
|
|
|
|
await vault.ForgetHostKeyCommand.ExecuteAsync(null);
|
|
|
|
// The refusal that sent the user here is about a pin that no longer exists, so it goes too.
|
|
vault.HasHostKeyMismatch.ShouldBeFalse();
|
|
|
|
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
|
|
|
// And the withdrawal was pushed rather than left for the timer: the other machines are the ones
|
|
// still refusing to connect to a server that has been rebuilt. The wording of the message is
|
|
// asserted in ForgettingAHostKeyThatWasNeverPinned_SaysSo, where no pass overwrites the status.
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForgettingAHostKeyThatWasNeverPinned_SaysSo()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
await vault.ForgetHostKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("Nothing was pinned");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ThereIsNothingToForgetOnAHostThatDoesNotExistYet()
|
|
{
|
|
// The button is hidden while a host is being created, because the pin belongs to an address that has
|
|
// not been saved anywhere yet.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.CanForgetHostKey.ShouldBeFalse();
|
|
|
|
vault.CancelEditCommand.Execute(null);
|
|
vault.CanForgetHostKey.ShouldBeFalse();
|
|
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.CanForgetHostKey.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile
|
|
/// and pass every other test, while leaving a discarded vault able to move focus in a locked window.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task LockingStopsTheShellForwardingFocusRequests()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
var requests = 0;
|
|
shell.TerminalSessionOpened += (_, _) => requests++;
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
requests.ShouldBe(1);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.Vault.ShouldBeNull();
|
|
|
|
// The discarded vault is detached, so even a late raise from it reaches nobody.
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
requests.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnInvalidHost_IsRefusedWithAReason()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = " ";
|
|
vault.EditorHostname = "db.internal";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.IsEditing.ShouldBeTrue("the editor should stay open so the user can fix it");
|
|
vault.Hosts.ShouldBeEmpty();
|
|
vault.Status.ShouldContain("needs a name");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// This is what earns the right to let a background pass fail silently. The automatic push after a save
|
|
/// is best-effort; the outbox is the durable part. If a failed pass dropped the change, "quiet" would
|
|
/// mean "lost".
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AQueueLeftByAFailedPass_IsStillSentByTheNextSync()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
server.LiveRowCount.ShouldBe(0, "the automatic push should have failed");
|
|
vault.PendingChanges.ShouldBe(1, "and the change should still be queued");
|
|
vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeTrue();
|
|
|
|
server.SyncFailure = null;
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
server.LiveRowCount.ShouldBe(1);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeFalse();
|
|
vault.Status.ShouldContain("Synchronised");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAHostRoundTripsThroughTheEditor()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorLabel.ShouldBe("prod-db");
|
|
vault.EditorHostname.ShouldBe("db.internal");
|
|
|
|
vault.EditorNotes = "rotate quarterly";
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.Notes.ShouldBe("rotate quarterly");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeletingAHost_RemovesItLocallyAndPushesTheTombstone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
await DeleteSelectedHostAsync(vault);
|
|
|
|
vault.Hosts.ShouldBeEmpty();
|
|
|
|
// Pushed without a second action. A tombstone that sat in the outbox would let the item come back
|
|
// on a machine that synced in the meantime.
|
|
server.LiveRowCount.ShouldBe(0);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
// ---- The question in front of a deletion ----
|
|
|
|
/// <remarks>
|
|
/// The half that makes the confirmation worth having: pressing DELETE has to change nothing at all. A
|
|
/// card that appeared after the item had already gone would be a receipt, not a question.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
|
|
vault.DeleteHostCommand.Execute(null);
|
|
|
|
var question = vault.PendingDeletion.ShouldNotBeNull();
|
|
question.Question.ShouldContain("prod-db", Case.Insensitive);
|
|
vault.IsConfirmingDeletion.ShouldBeTrue();
|
|
vault.ShowsHostActions.ShouldBeFalse("the buttons are what the question replaces");
|
|
|
|
vault.Hosts.ShouldHaveSingleItem();
|
|
server.LiveRowCount.ShouldBe(1);
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldBeEmpty();
|
|
vault.PendingDeletion.ShouldBeNull("the question goes when it is answered");
|
|
vault.ShowsHostActions.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancellingADeletion_LeavesTheItemWhereItWas()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
|
|
vault.DeleteCredentialCommand.Execute(null);
|
|
vault.CancelDeleteCommand.Execute(null);
|
|
|
|
vault.PendingDeletion.ShouldBeNull();
|
|
|
|
// And the answer that would have deleted it has nothing left to act on.
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Credentials.ShouldHaveSingleItem();
|
|
server.LiveRowCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// What the question is for. A key that two hosts authenticate with is not the same deletion as one
|
|
/// nothing uses, and the hosts do not fall back to a typed password when it goes — they refuse, which is
|
|
/// asserted from the connect path's side in
|
|
/// <see cref="AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword"/>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheQuestionAboutAKey_CountsTheHostsThatAuthenticateWithIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
var keyId = vault.Keys[0].EntityId;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "prod-web");
|
|
await BindKeyAsync(vault, Host(vault, "prod-db"), keyId);
|
|
await BindKeyAsync(vault, Host(vault, "prod-web"), keyId);
|
|
|
|
vault.SelectedKey = vault.Keys[0];
|
|
vault.DeleteKeyCommand.Execute(null);
|
|
|
|
var question = vault.PendingDeletion.ShouldNotBeNull();
|
|
question.HasUsage.ShouldBeTrue();
|
|
question.Usage.ShouldContain("2 hosts");
|
|
question.Usage.ShouldContain("prod-db");
|
|
question.Usage.ShouldContain("prod-web");
|
|
|
|
// And the sentence above it says how far the deletion travels, which needs no host at all.
|
|
question.Consequence.ShouldContain("no undo", Case.Insensitive);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A key nothing uses gets no scare line, which is the other half of counting: a warning that appeared
|
|
/// every time would say nothing the second time.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheQuestionAboutAKeyNothingUses_SaysNothingAboutHosts()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "spare");
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedKey = vault.Keys[0];
|
|
vault.DeleteKeyCommand.Execute(null);
|
|
|
|
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The failure this guards against is a question answered about something else: arm the deletion, click
|
|
/// another row, press the button that is still on screen. The armed item is what the answer acts on, and
|
|
/// choosing a different one takes the question away rather than re-aiming it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ChoosingSomethingElse_TakesTheQuestionAway()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
await AddCredentialAsync(vault, "staging deploy");
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.Section = VaultSection.Credentials;
|
|
vault.SelectedVaultItem = vault.VaultItems[0];
|
|
|
|
vault.DeleteCredentialCommand.Execute(null);
|
|
vault.PendingDeletion.ShouldNotBeNull();
|
|
|
|
vault.SelectedVaultItem = vault.VaultItems[1];
|
|
|
|
vault.PendingDeletion.ShouldBeNull();
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Credentials.Count.ShouldBe(2, "nothing was agreed to");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The case the naive rule got wrong. A reload replaces every row object in the list, so disarming on
|
|
/// any change of the selected <em>row</em> would let the pass that runs every minute take the card away
|
|
/// from somebody halfway through reading it. The entity id is what the rule compares.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ASyncUnderneathAnArmedQuestion_LeavesItAlone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
|
|
vault.DeleteHostCommand.Execute(null);
|
|
var armed = vault.PendingDeletion.ShouldNotBeNull();
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.PendingDeletion.ShouldBe(armed);
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Opening an editor is the other way the pane the question is in stops being about the question: the
|
|
/// vault screen's Add buttons stay on screen beside the detail pane, so a password editor can open over
|
|
/// an armed deletion. It disarms rather than stacking two forms in a 244-pixel column.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OpeningAnEditor_TakesTheQuestionAway()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
|
|
vault.DeleteCredentialCommand.Execute(null);
|
|
vault.PendingDeletion.ShouldNotBeNull();
|
|
|
|
vault.NewCredentialCommand.Execute(null);
|
|
|
|
vault.IsEditingCredential.ShouldBeTrue();
|
|
vault.PendingDeletion.ShouldBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// An answer to a question about something that has since gone — the realistic way being a pass that
|
|
/// pulled somebody else's deletion. The reload that brings that news normally moves the selection and
|
|
/// takes the question with it; this holds the guard behind that, which is what keeps a stale agreement
|
|
/// from being a silent no-op under a card that has just been pressed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnsweringAboutSomethingAlreadyGone_SaysSo()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
vault.SelectedKey = vault.Keys[0];
|
|
|
|
vault.DeleteKeyCommand.Execute(null);
|
|
vault.PendingDeletion.ShouldNotBeNull();
|
|
|
|
// Underneath the question, as another machine's deletion would arrive.
|
|
await vault.Session.SshKeys.DeleteAsync(
|
|
vault.Session.ActiveVaultId, vault.Keys[0].EntityId, Token);
|
|
await vault.LoadAsync(Token);
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("no longer here");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
|
{
|
|
await EnrolledAndConfirmedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
// Locking does not drop the connection, so take a fresh shell that never signed in.
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
new UnavailableDeviceKeyStore(),
|
|
(_, _) => throw new InvalidOperationException("unreachable"),
|
|
TimeProvider.System,
|
|
ssh,
|
|
CheapProfile);
|
|
|
|
await using var _ = offline.ConfigureAwait(false);
|
|
|
|
await offline.StartAsync(Token);
|
|
offline.Passphrase = Passphrase;
|
|
await offline.UnlockCommand.ExecuteAsync(null);
|
|
|
|
var vault = offline.Vault!;
|
|
await AddHostAsync(vault, "offline-host");
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("Offline");
|
|
vault.PendingChanges.ShouldBe(1, "the change is kept, not discarded");
|
|
server.PushCount.ShouldBe(0);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The one connect test that deliberately attaches no <see cref="FakeRenderer"/>: the listener is up
|
|
/// and nothing ever connects to it, which from the view model's side is indistinguishable from a
|
|
/// WebView2 that failed to initialise on a user's machine. The interesting assertion is the second
|
|
/// one: while the wait was unbounded this hung with the busy flag set, so the window stayed disabled
|
|
/// and said "Connecting…" for the rest of the session.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingWithNoRenderer_ExplainsItselfAndReleasesTheWindow()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
// Naming the runtime is the whole point: a bare "The operation has timed out" sends someone
|
|
// looking at their network or their host.
|
|
vault.Status.ShouldContain("WebView2");
|
|
vault.IsBusy.ShouldBeFalse("a connect that gave up must not leave the window disabled");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LockingForgetsTheVault()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
shell.Vault.ShouldBeNull();
|
|
|
|
// And unlocking again works, so locking released rather than corrupted anything.
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
shell.State.ShouldBe(ShellState.Unlocked);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The deliberate half of what Lock does: the vault closes, the shells do not.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A policy rather than an implementation detail, which is why it is asserted here. Locking is what a
|
|
/// person does when they walk away from the machine, and that is exactly when a long job is most
|
|
/// likely to be running — so ending every shell would make Lock destroy work, and an idle auto-lock
|
|
/// would do it unattended. <c>MainWindowViewModel.LockAsync</c> carries the full argument.
|
|
/// </para>
|
|
/// <para>
|
|
/// The disclosure is asserted along with the behaviour, because the two are the same decision. A
|
|
/// lock screen that hides the terminal — which it does, the WebView is collapsed while locked — while
|
|
/// authenticated SSH channels stay open is only defensible if it says so.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task LockingKeepsOpenShellsRunning_AndSaysSoOnTheUnlockScreen()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
// Opened on the workspace rather than through Connect. Connect would work here — FakeRenderer can
|
|
// satisfy the renderer gate — but what is under test is what Lock does to a session that exists,
|
|
// not how it came to exist, and going through the gate would only add a way for this to fail.
|
|
await workspace.OpenSessionAsync(
|
|
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
|
TerminalSize.Default,
|
|
Token);
|
|
|
|
workspace.LiveSessionCount.ShouldBe(1);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
shell.Vault.ShouldBeNull("the vault's keys are gone");
|
|
|
|
workspace.LiveSessionCount.ShouldBe(1, "the shell was still running, so it kept running");
|
|
|
|
shell.HasLiveSessions.ShouldBeTrue();
|
|
shell.LiveSessionCount.ShouldBe(1);
|
|
shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");
|
|
|
|
// And it survives the unlock too, so the session outlives the whole cycle rather than merely
|
|
// outliving the disposal.
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked);
|
|
workspace.LiveSessionCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LockingWithNoOpenShells_DisclosesNothing()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.LiveSessionCount.ShouldBe(0);
|
|
shell.HasLiveSessions.ShouldBeFalse("an ordinary lock must not warn about nothing");
|
|
}
|
|
|
|
// ---- SSH keys ----
|
|
|
|
[Fact]
|
|
public async Task AddingAKey_ShowsItImmediatelyAndPushesIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.IsEditingKey.ShouldBeTrue();
|
|
|
|
vault.KeyEditorLabel = "deploy";
|
|
vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
|
|
vault.KeyEditorPassphrase = "hunter2";
|
|
|
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.IsEditingKey.ShouldBeFalse();
|
|
|
|
var row = vault.Keys.ShouldHaveSingleItem();
|
|
row.Label.ShouldBe("deploy");
|
|
row.Description.ShouldBe("passphrase · no public half");
|
|
row.HasUnsyncedChanges.ShouldBeFalse("saving pushes, so nothing should still be pending");
|
|
|
|
vault.PendingChanges.ShouldBe(0);
|
|
server.LiveRowCount.ShouldBe(1, "a saved key should reach the server without pressing Sync");
|
|
|
|
// And the host list is untouched, so the two lists are genuinely separate.
|
|
vault.Hosts.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddingAKey_BindsItToNothing()
|
|
{
|
|
// Storing a key must not change how any host authenticates. The failure this rules out is a key
|
|
// nobody chose being offered to a host — a credential leaving the vault by accident.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
|
|
vault.Hosts[0].Authentication.ShouldBe("password");
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAKey_RoundTripsThroughTheEditorIncludingTheMaterial()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedKey = vault.Keys[0];
|
|
vault.EditSelectedKeyCommand.Execute(null);
|
|
|
|
// The material has to come back into the editor. The codec has no partial update, so a save
|
|
// re-encodes every field — an editor that loaded a blank private key would erase it.
|
|
vault.KeyEditorLabel.ShouldBe("deploy");
|
|
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("MATERIAL"));
|
|
vault.KeyEditorPassphrase.ShouldBe("hunter2");
|
|
|
|
vault.KeyEditorNotes = "rotate quarterly";
|
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
var saved = vault.Keys.ShouldHaveSingleItem().Key;
|
|
saved.Notes.ShouldBe("rotate quarterly");
|
|
saved.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"));
|
|
saved.Passphrase.ShouldBe("hunter2");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancellingTheKeyEditor_LeavesNoMaterialBehindInIt()
|
|
{
|
|
// The editor holds a private key in a bound property for as long as it is open. It cannot be wiped
|
|
// — see SshKeySecret — but it can stop being referenced, and an abandoned editor that kept the key
|
|
// would hand it to whatever opened next.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorLabel = "deploy";
|
|
vault.KeyEditorPrivateKey = PrivateKey("ABANDONED");
|
|
vault.KeyEditorPassphrase = "hunter2";
|
|
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
|
|
vault.IsEditingKey.ShouldBeFalse();
|
|
vault.KeyEditorPrivateKey.ShouldBeEmpty();
|
|
vault.KeyEditorPassphrase.ShouldBeEmpty();
|
|
vault.KeyEditorLabel.ShouldBeEmpty();
|
|
vault.Keys.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task APublicKeyPastedIntoThePrivateField_NamesTheActualMistake()
|
|
{
|
|
// ssh-keygen writes two files whose names differ by four characters. The message has to say which
|
|
// one to pick, because the alternative is an authentication failure at connect time that says
|
|
// nothing about the file.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorLabel = "deploy";
|
|
vault.KeyEditorPrivateKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop";
|
|
|
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain(".pub");
|
|
vault.Keys.ShouldBeEmpty();
|
|
vault.IsEditingKey.ShouldBeTrue("the editor stays open so the paste can be corrected");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeletingAKey_RemovesItLocallyAndPushesTheTombstone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
vault.SelectedKey = vault.Keys[0];
|
|
await DeleteSelectedKeyAsync(vault);
|
|
|
|
vault.Keys.ShouldBeEmpty();
|
|
server.LiveRowCount.ShouldBe(0);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
// ---- One kind of item at a time ----
|
|
|
|
/// <remarks>
|
|
/// Hosts are not one of these any more. They have their own screen beside the terminal, which is what the
|
|
/// design asks for and is the better split anyway: the host list is what you look at while you work, and
|
|
/// the keys and passwords behind it are what you go and manage. What is left here is the vault screen's
|
|
/// own rail, and it opens on everything at once because the categories are a filter over one table
|
|
/// rather than four separate lists.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheKeychainScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.Section.ShouldBe(VaultSection.All);
|
|
vault.ShowsAll.ShouldBeTrue();
|
|
vault.ShowsKeys.ShouldBeFalse();
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.Keys);
|
|
|
|
vault.ShowsKeys.ShouldBeTrue();
|
|
vault.ShowsAll.ShouldBeFalse("both flags are one fact read two ways and cannot both be true");
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
vault.ShowsAll.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The merged category is what the design's one credential table exists for, and the thing worth pinning
|
|
/// about it is that it is a projection rather than a fifth list: every row maps back to the typed row the
|
|
/// editors and the delete commands already act on.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheMergedTableCarriesEveryKindAndSelectingARowSelectsTheTypedOne()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
|
|
|
// Adding leaves the rail on whatever was added last, because opening an editor brings its own
|
|
// category into view. Back to the merged one, which is where the screen opens.
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
|
|
vault.ShowsAll.ShouldBeTrue();
|
|
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy", "pg-primary"]);
|
|
vault.VaultItems.Select(row => row.Type).ShouldBe(["SSH KEY", "PASSWORD"]);
|
|
|
|
vault.SelectedVaultItem = vault.VaultItems.First(row => row.Kind is VaultItemKind.Credential);
|
|
|
|
vault.SelectedCredential.ShouldNotBeNull();
|
|
vault.SelectedCredential.Label.ShouldBe("pg-primary");
|
|
vault.SelectedItemIsEditable.ShouldBeTrue();
|
|
|
|
// And narrowing to one kind does not disturb what is selected underneath.
|
|
vault.ShowSectionCommand.Execute(VaultSection.Keys);
|
|
|
|
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy"]);
|
|
vault.SelectedCredential.Label.ShouldBe("pg-primary", "narrowing the view is not a deselection");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The invariant that makes an unreachable editor impossible: an editor is only ever open in the section
|
|
/// that is showing. Without it, adding a key from a keyboard shortcut or a future menu would open an
|
|
/// editor nobody can see, holding a private key nobody can cancel.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OpeningAnEditorBringsItsOwnSectionIntoView()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
vault.Section = VaultSection.All;
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open with the rail pointing elsewhere");
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
|
|
// And through the other door into the editor.
|
|
vault.Section = VaultSection.All;
|
|
vault.SelectedKey = vault.Keys[0];
|
|
vault.EditSelectedKeyCommand.Execute(null);
|
|
vault.ShowsKeys.ShouldBeTrue();
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
|
|
// The host editor is the exemption, and it is the point of the split: hosts are a screen of their
|
|
// own, so opening their editor has no category to bring into view and must not move the rail.
|
|
vault.Section = VaultSection.Keys;
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.IsEditing.ShouldBeTrue();
|
|
vault.ShowsKeys.ShouldBeTrue("editing a host is not a reason to move the vault screen's rail");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The refusal that keeps the rule above true. Leaving the section while a vault-screen editor is open
|
|
/// would hide it, and in the key editor's case that means a pasted private key sitting in a form with
|
|
/// nothing on screen to say it is there.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SwitchingSectionIsRefusedWhileAKeychainScreenEditorIsOpen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
|
|
vault.ShowsKeys.ShouldBeTrue("the selector must not move away from an open editor");
|
|
vault.Status.ShouldContain("SSH key");
|
|
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
|
|
|
|
// A refusal, not a lockout: dealing with the editor releases the selector.
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
vault.ShowsAll.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The host editor lives on a different screen from the rail, so it does not guard the rail — nothing
|
|
/// about a half-typed host is visible or at risk from switching the vault screen's own category, and
|
|
/// blocking it here used to leave three quarters of that screen inert with a message pointing at an
|
|
/// editor the user could not see.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SwitchingSectionIsNotBlockedByAnOpenHostEditor()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = "half-typed";
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.Keys);
|
|
|
|
vault.ShowsKeys.ShouldBeTrue("a host editor on another screen has nothing to say about this rail");
|
|
vault.IsEditing.ShouldBeTrue("switching category must not close the host editor either");
|
|
vault.EditorLabel.ShouldBe("half-typed");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Asking for the section that is already showing is not a refusal, so a second click on the selected
|
|
/// button while an editor is open says nothing. Worth pinning because the obvious implementation — check
|
|
/// the editor, then compare — would scold somebody for clicking where they already are.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ReselectingTheSectionAlreadyShowingSaysNothing()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.Status = string.Empty;
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
|
|
vault.Status.ShouldBeEmpty();
|
|
vault.IsEditing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One editor open at a time <em>within</em> the vault screen, still — but no longer across it and the
|
|
/// Hosts screen. Both editors used to be <c>Auto</c> rows in one 340-pixel column whose combined height
|
|
/// exceeded it; sections ended that, and the design import gave hosts their own screen, so the host
|
|
/// editor no longer shares any column, any visibility or any risk with the key and credential editors.
|
|
/// What the rule still buys, inside the vault screen, is that an open key editor is always one somebody
|
|
/// can see, because it is holding their private key.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostEditorAndAKeychainScreenEditorCanBeOpenTogether()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
|
|
// Both open at once: the host editor is on a screen of its own, and there is nothing for it to
|
|
// clip or hide on the vault screen the key editor is on.
|
|
vault.IsEditing.ShouldBeTrue("the host editor is a different screen's business now");
|
|
vault.IsEditingKey.ShouldBeTrue("and does not close the vault screen's own editor");
|
|
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One vault-screen editor at a time, still. The key and credential editors share the same screen and
|
|
/// the same detail pane, so opening one over the other is exactly the case the rule exists for — unlike
|
|
/// the host editor, which does not.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OnlyOneKeychainScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");
|
|
|
|
vault.NewCredentialCommand.Execute(null);
|
|
|
|
vault.IsEditingCredential.ShouldBeFalse("the credential editor must not open over the key editor");
|
|
vault.IsEditingKey.ShouldBeTrue();
|
|
vault.Status.ShouldContain("SSH key");
|
|
|
|
// The refusal is worth nothing if it costs the paste.
|
|
vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
|
|
|
|
// And it is a refusal, not a lockout.
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
vault.NewCredentialCommand.Execute(null);
|
|
vault.IsEditingCredential.ShouldBeTrue();
|
|
|
|
// Symmetrically, with the credential editor holding the screen.
|
|
vault.CredentialEditorPassword = "half-typed";
|
|
vault.NewKeyCommand.Execute(null);
|
|
|
|
vault.IsEditingKey.ShouldBeFalse();
|
|
vault.CredentialEditorPassword.ShouldBe("half-typed");
|
|
vault.Status.ShouldContain("credential");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherKeychainScreenEditorToo()
|
|
{
|
|
// The Edit commands are a second door into the same screen, and guarding only the Add ones would
|
|
// leave it wide open.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
|
|
|
vault.SelectedKey = vault.Keys[0];
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.EditSelectedCredentialCommand.Execute(null);
|
|
vault.IsEditingCredential.ShouldBeFalse();
|
|
|
|
vault.CancelKeyEditCommand.Execute(null);
|
|
|
|
vault.NewCredentialCommand.Execute(null);
|
|
vault.EditSelectedKeyCommand.Execute(null);
|
|
vault.IsEditingKey.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The host editor's own version of the same rule: opening a second host editor over the first, or
|
|
/// editing an existing host while adding one, is refused — this is the one case the split guard still
|
|
/// has to cover, because both doors lead to the same single editor on the Hosts screen.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostEditorRefusesToOpenOverItself()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = "half-typed";
|
|
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorLabel.ShouldBe("half-typed", "the second door must not discard the first editor's draft");
|
|
vault.Status.ShouldContain("host");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ── v5b ── The rail's own count chips, per category — Keychain.dc.html draws a right-aligned mono
|
|
/// count beside every category row, and this pins that each one is the same number the category's own
|
|
/// list already carries rather than a second, hand-kept tally that could drift from it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheCategoryRailCountsMatchTheUnderlyingLists()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddKeyAsync(vault, "backup");
|
|
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
|
await AddTagAsync(vault, "production");
|
|
|
|
vault.Keys.Count.ShouldBe(2);
|
|
vault.Credentials.Count.ShouldBe(1);
|
|
vault.Tags.Count.ShouldBe(1);
|
|
vault.ObjectStores.Count.ShouldBe(0);
|
|
vault.TotalItemCount.ShouldBe(4, "ALL's own chip counts every kind, buckets and tags included");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ── v5b ── The type glyph Keychain.dc.html draws beside every row's name. Pinned per kind, because a
|
|
/// glyph that silently fell back to the same one for two kinds would make ALL unreadable at a glance —
|
|
/// which is the whole reason the column exists.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EachVaultItemKindCarriesItsOwnGlyph()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
|
await AddTagAsync(vault, "production");
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
|
|
var byKind = vault.VaultItems.ToDictionary(row => row.Kind, row => row.IconGlyph);
|
|
|
|
byKind[VaultItemKind.Key].ShouldNotBeNullOrEmpty();
|
|
byKind[VaultItemKind.Credential].ShouldNotBeNullOrEmpty();
|
|
byKind[VaultItemKind.Tag].ShouldNotBeNullOrEmpty();
|
|
|
|
new[] { byKind[VaultItemKind.Key], byKind[VaultItemKind.Credential], byKind[VaultItemKind.Tag] }
|
|
.Distinct(StringComparer.Ordinal).Count()
|
|
.ShouldBe(3, "three kinds on the same table read as three different glyphs");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ── v5b ── The filter box Keychain.dc.html adds to the table's own sub-toolbar — this table never had
|
|
/// one before. It has to narrow and nothing else: a row it hides is still in the underlying list, and
|
|
/// clearing it brings every row straight back.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheItemFilterNarrowsTheMergedTableByNameOrType()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy-key");
|
|
await AddCredentialAsync(vault, "pg-primary", "s3cret");
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
|
|
vault.ItemFilter = "deploy";
|
|
|
|
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy-key"]);
|
|
|
|
vault.ItemFilter = "PASSWORD";
|
|
|
|
vault.VaultItems.Select(row => row.Name).ShouldBe(
|
|
["pg-primary"], "the type word matches too, case-insensitively");
|
|
|
|
vault.ItemFilter = string.Empty;
|
|
|
|
vault.VaultItems.Count.ShouldBe(2, "clearing the filter is not a second deletion");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ── v5b ── The detail pane's own USED BY list and "in use" chip, and the table's USED BY column — all
|
|
/// three read the same resolved-binding scan <c>HostsBoundTo</c> already used for the deletion warning,
|
|
/// so this pins that a host genuinely bound to a key shows up in all three rather than in only one of
|
|
/// them going stale relative to the others.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AKeyBoundToAHost_ShowsUpInTheUsedByFactsEverywhereTheyAreDrawn()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-api-01");
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
var key = vault.Keys[0];
|
|
await BindKeyAsync(vault, Host(vault, "prod-api-01"), key.EntityId);
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
var row = vault.VaultItems.Single(item => item.Kind is VaultItemKind.Key);
|
|
|
|
row.UsedBySummary.ShouldBe("prod-api-01");
|
|
row.HasUsedBySummary.ShouldBeTrue();
|
|
|
|
vault.SelectedVaultItem = row;
|
|
|
|
vault.HasSelectedItemUsedByHosts.ShouldBeTrue();
|
|
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-api-01");
|
|
vault.SelectedItemInUseSummary.ShouldBe("in use · 1 host");
|
|
|
|
// And a key nothing authenticates with says none of this — never a zero-count claim.
|
|
await AddKeyAsync(vault, "unused");
|
|
vault.SelectedVaultItem = vault.VaultItems.Single(
|
|
item => string.Equals(item.Name, "unused", StringComparison.Ordinal));
|
|
|
|
vault.HasSelectedItemUsedByHosts.ShouldBeFalse();
|
|
vault.SelectedItemInUseSummary.ShouldBe(string.Empty);
|
|
}
|
|
|
|
// ---- Keeping an open editor's pickers in step with the vault ----
|
|
|
|
/// <remarks>
|
|
/// The other side of the split guard, and the bug it left behind. The host editor and the keychain's
|
|
/// editors are on different screens and refuse each other no longer, so a key is very often added
|
|
/// <em>because</em> the host in front of the user needs one — with that host's editor still standing on the
|
|
/// Hosts screen. The picker was a snapshot taken when the editor opened, so the key never appeared in it
|
|
/// and the only way to reach it was to abandon the edit and start again.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AKeyAddedWithTheHostEditorOpen_AppearsInItsAuthenticationPicker()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorPort = 2244;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
vault.IsEditing.ShouldBeTrue("adding a key must not close the host editor");
|
|
vault.EditorPort.ShouldBe(2244, "nor discard what has been typed into it");
|
|
|
|
var offered = vault.EditorAuthenticationChoices.Single(
|
|
choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal));
|
|
|
|
offered.Kind.ShouldBe(AuthenticationKind.SshKey);
|
|
offered.EntityId.ShouldBe(vault.Keys[0].EntityId);
|
|
|
|
// A real entry rather than a label: choosing it and saving is what the user came here to do.
|
|
vault.EditorSelectedAuthentication = offered;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(vault.Keys[0].EntityId);
|
|
vault.Hosts[0].Host.Port.ShouldBe(2244);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The counterweight, and the reason the refill restores each picker by id rather than reloading the
|
|
/// stored host: a list that grows under somebody halfway through a form must not move what they had
|
|
/// already chosen in it, and the save that follows must not rebind the host to something nobody picked.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnItemArrivingWhileTheHostEditorIsOpen_LeavesItsSelectionWhereItWas()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
var keyId = vault.Keys.ShouldHaveSingleItem().EntityId;
|
|
|
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
await AddCredentialAsync(vault, "pg-primary");
|
|
|
|
vault.EditorAuthenticationChoices.ShouldContain(
|
|
choice => choice.Kind == AuthenticationKind.Credential);
|
|
|
|
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId
|
|
.ShouldBe(keyId, "a credential appearing must not unbind the host from its key");
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
|
|
vault.Hosts[0].Host.CredentialId.ShouldBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The moment a credential is wanted is the moment somebody is choosing how a host authenticates and
|
|
/// finds it is not in the keychain yet, so the host editor makes one. Selecting it has to survive the
|
|
/// reload the write triggers, which is the part that needs a test: the refill rebuilds the picker from
|
|
/// the vault and restores it from the editor's own selection, so the binding is written before the
|
|
/// reload rather than after it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ACredentialMadeInTheHostEditor_BindsTheHostToIt()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.BeginEditorCredentialCommand.Execute(null);
|
|
vault.IsAddingEditorCredential.ShouldBeTrue();
|
|
|
|
vault.EditorNewCredentialLabel = "pg-primary";
|
|
vault.EditorNewCredentialUsername = "postgres";
|
|
vault.EditorNewCredentialPassword = "s3cret";
|
|
vault.EditorNewCredentialNotes = "rotated quarterly";
|
|
|
|
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
|
|
|
var credential = vault.Credentials.ShouldHaveSingleItem();
|
|
credential.Credential.Username.ShouldBe("postgres");
|
|
credential.Credential.Notes.ShouldBe("rotated quarterly");
|
|
|
|
vault.IsAddingEditorCredential.ShouldBeFalse("the form closes once the credential is in the keychain");
|
|
vault.EditorNewCredentialPassword.ShouldBeEmpty("the form must not go on holding the password");
|
|
|
|
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(
|
|
credential.EntityId,
|
|
"the picker has to land on the credential that was just made, through the reload");
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credential.EntityId);
|
|
vault.Hosts[0].Host.SshKeyId.ShouldBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The honest consequence of writing immediately, and the same one the new-tag box already carries: a
|
|
/// credential is a shared item with an id, the host can only name an id that exists, so the credential
|
|
/// was never part of the host to begin with. What was still being typed is a different matter — that
|
|
/// includes a password, and it goes with the editor it was typed into.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task CancellingTheHostEditor_KeepsTheCredentialItMade_AndDropsWhatWasStillBeingTyped()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.BeginEditorCredentialCommand.Execute(null);
|
|
vault.EditorNewCredentialLabel = "pg-primary";
|
|
vault.EditorNewCredentialPassword = "s3cret";
|
|
|
|
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
|
|
|
// A second one, opened and left half-typed.
|
|
vault.BeginEditorCredentialCommand.Execute(null);
|
|
vault.EditorNewCredentialLabel = "half";
|
|
vault.EditorNewCredentialPassword = "typed-but-never-added";
|
|
vault.EditorNewCredentialNotes = "half a thought";
|
|
|
|
vault.CancelEditCommand.Execute(null);
|
|
|
|
vault.Credentials.ShouldHaveSingleItem().Label.ShouldBe("pg-primary");
|
|
|
|
vault.IsAddingEditorCredential.ShouldBeFalse();
|
|
vault.EditorNewCredentialLabel.ShouldBeEmpty();
|
|
vault.EditorNewCredentialNotes.ShouldBeEmpty();
|
|
vault.EditorNewCredentialPassword.ShouldBeEmpty(
|
|
"a password typed into an abandoned form must not survive behind the next host");
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(
|
|
"the binding itself was never saved");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Why this form has fields of its own rather than reusing the keychain screen's four.
|
|
/// <c>IsEditingCredential</c> is what <c>AVaultEditorIsInTheWay</c> asks about, so sharing it would make
|
|
/// the whole Vault screen refuse to open an editor, with a sentence naming a form the user cannot see
|
|
/// on a screen they are not looking at. That is the exact failure the guard was split in two to end.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostEditorsCredentialForm_DoesNotBlockTheKeychainsOwnEditors()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.BeginEditorCredentialCommand.Execute(null);
|
|
|
|
vault.NewCredentialCommand.Execute(null);
|
|
|
|
vault.IsEditingCredential.ShouldBeTrue(
|
|
"the keychain's editor lives on another screen and opens regardless");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Where this deliberately parts from the new-tag box beside it, which offers an existing tag rather
|
|
/// than repeating it. Two tags called "staging" are one intention spelled twice; two credentials called
|
|
/// "root" are two different passwords, and quietly binding the host to whichever was there already
|
|
/// would authenticate it as an account nobody chose.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ACredentialMadeInTheHostEditor_UnderANameAlreadyTaken_IsASecondCredential()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddCredentialAsync(vault, "root", password: "first");
|
|
|
|
var first = vault.Credentials.ShouldHaveSingleItem().EntityId;
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.BeginEditorCredentialCommand.Execute(null);
|
|
vault.EditorNewCredentialLabel = "root";
|
|
vault.EditorNewCredentialPassword = "second";
|
|
|
|
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
|
|
|
vault.Credentials.Count.ShouldBe(2);
|
|
|
|
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldNotBe(
|
|
first,
|
|
"binding to the credential that happened to share the name would be the wrong password");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The same refusal <c>CredentialSecret.TryValidate</c> gives the keychain's editor, reaching the user
|
|
/// here rather than producing an item that looks usable and fails at the handshake.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ACredentialMadeInTheHostEditor_WithNoPassword_IsRefused()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.BeginEditorCredentialCommand.Execute(null);
|
|
vault.EditorNewCredentialLabel = "pg-primary";
|
|
|
|
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
|
|
|
vault.Credentials.ShouldBeEmpty();
|
|
vault.IsAddingEditorCredential.ShouldBeTrue("the form stays open on what it refused");
|
|
vault.Status.ShouldContain("password");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Tags reach the same editor by a different route — the keychain screen rather than the box under the
|
|
/// chips — and a chip that only appeared on the next open would send the user round the same detour.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ATagAddedWithTheHostEditorOpen_AppearsAmongItsChips()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
await AddTagAsync(vault, "production");
|
|
|
|
vault.HasTagChoices.ShouldBeTrue();
|
|
|
|
var chip = vault.EditorTagChoices.ShouldHaveSingleItem();
|
|
chip.Label.ShouldBe("production");
|
|
chip.IsWorn.ShouldBeFalse("appearing is not the same as being put on");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The group editor shares the drawer with the host editor and its own picker was the same snapshot, so
|
|
/// the same detour applied to the default binding a whole group of hosts inherits.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AKeyAddedWithTheGroupEditorOpen_AppearsInItsDefaultBindingPicker()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddGroupAsync(vault, "platform");
|
|
|
|
vault.EditGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
|
|
vault.GroupEditorDefaultPort = 2222;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
vault.IsEditingGroup.ShouldBeTrue("adding a key must not close the group editor");
|
|
vault.GroupEditorDefaultPort.ShouldBe(2222, "nor discard what has been typed into it");
|
|
|
|
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices.Single(
|
|
choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal));
|
|
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
|
|
vault.Groups.ShouldHaveSingleItem().Group.DefaultSshKeyId.ShouldBe(vault.Keys[0].EntityId);
|
|
vault.Groups[0].Group.DefaultPort.ShouldBe(2222);
|
|
}
|
|
|
|
// ---- Binding a key to a host ----
|
|
|
|
[Fact]
|
|
public async Task BindingAKeyToAHost_RoundTripsThroughTheEditorAndTheVault()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
var keyId = vault.Keys[0].EntityId;
|
|
|
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
|
|
vault.Hosts[0].Authentication.ShouldBe("key");
|
|
|
|
// Through the server and back, which is what makes it a property of the host rather than of this
|
|
// machine — the whole reason it is a payload field and not a local preference.
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
|
|
|
|
// And it is offered back correctly when the editor reopens, including as the current selection.
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(keyId);
|
|
vault.EditorAuthenticationChoices[0].Kind
|
|
.ShouldBe(AuthenticationKind.Typed, "the typed-password entry stays first");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHostWithNoKey_UsesThePassword()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
// A key exists in the vault and is even selected in the key list. An unbound host must still use
|
|
// the password: the list selection is for editing keys, not for deciding authentication.
|
|
await AddKeyAsync(vault, "deploy");
|
|
vault.SelectedKey = vault.Keys[0];
|
|
vault.ConnectPassword = "typed-in";
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
ssh.Requests.ShouldHaveSingleItem().Credential
|
|
.ShouldBeOfType<SshPasswordCredential>()
|
|
.Password.ShouldBe("typed-in");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHostBoundToAKey_HandsTheSshStackTheKeyAndItsPassphrase()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
|
|
vault.ConnectPassword = "should-not-be-used";
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
var credential = ssh.Requests.ShouldHaveSingleItem().Credential
|
|
.ShouldBeOfType<SshPrivateKeyCredential>();
|
|
|
|
System.Text.Encoding.UTF8.GetString(credential.PrivateKeyPem)
|
|
.ShouldBe(PrivateKey("MATERIAL"));
|
|
|
|
credential.Passphrase.ShouldBe("hunter2");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AKeyWithABlankPassphraseBox_IsAKeyWithNoPassphrase()
|
|
{
|
|
// Blank and absent are one state, from the editor all the way to the credential. The list has to say
|
|
// so too, because "passphrase" against a key that has none sends someone hunting for one they never
|
|
// set — and SSH.NET will not correct them: it ignores a passphrase on an unprotected key rather than
|
|
// refusing it. See docs/platform-flags.md.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy", passphrase: string.Empty);
|
|
|
|
vault.Keys.ShouldHaveSingleItem().Description.ShouldStartWith("no passphrase");
|
|
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
ssh.Requests.ShouldHaveSingleItem().Credential
|
|
.ShouldBeOfType<SshPrivateKeyCredential>()
|
|
.Passphrase.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword()
|
|
{
|
|
// A key deleted on another machine is ordinary, and this is what it must not cause: a host somebody
|
|
// deliberately set up for key-only access quietly starting to offer a password instead.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
|
|
vault.SelectedKey = vault.Keys[0];
|
|
await DeleteSelectedKeyAsync(vault);
|
|
vault.Keys.ShouldBeEmpty();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.ConnectPassword = "must-not-be-sent";
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
|
|
vault.Status.ShouldContain("not in this keychain");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAHostWhoseKeyHasBeenDeleted_DoesNotQuietlyUnbindIt()
|
|
{
|
|
// The same failure one step removed, and the subtler one. Someone opens the host to change its port;
|
|
// if the picker had silently fallen back to "no key", saving would convert it to password
|
|
// authentication and nothing would ever have said so.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
|
|
var keyId = vault.Keys[0].EntityId;
|
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
|
|
|
vault.SelectedKey = vault.Keys[0];
|
|
await DeleteSelectedKeyAsync(vault);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
// The binding is still there, still selected, and says what is wrong with it.
|
|
var selected = vault.EditorSelectedAuthentication.ShouldNotBeNull();
|
|
selected.EntityId.ShouldBe(keyId);
|
|
selected.Kind.ShouldBe(AuthenticationKind.SshKey, "a missing key must not come back as a credential");
|
|
selected.Label.ShouldContain("no longer here");
|
|
|
|
vault.EditorPort = 2244;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.Port.ShouldBe(2244);
|
|
vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId, "an unrelated edit must not drop the binding");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemovingABinding_PutsTheHostBackOnAPassword()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
|
.Single(choice => choice.Kind is AuthenticationKind.Typed);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
|
|
vault.Hosts[0].Authentication.ShouldBe("password");
|
|
|
|
vault.ConnectPassword = "typed-in";
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType<SshPasswordCredential>();
|
|
}
|
|
|
|
// ---- Stored credentials ----
|
|
|
|
[Fact]
|
|
public async Task ACredentialRoundTripsThroughTheEditorAndTheVault()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");
|
|
|
|
var row = vault.Credentials.ShouldHaveSingleItem();
|
|
row.Label.ShouldBe("prod deploy");
|
|
row.Description.ShouldBe("deploy", "the account is what the row has to show");
|
|
|
|
// Through the server and back, which is the whole point of storing it in the vault rather than on the
|
|
// machine that typed it.
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");
|
|
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
vault.EditSelectedCredentialCommand.Execute(null);
|
|
|
|
vault.CredentialEditorPassword.ShouldBe(
|
|
"s3cret", "the editor has to load it, because saving re-encodes every field");
|
|
vault.CredentialEditorUsername.ShouldBe("deploy");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ACredentialWithNoUsername_SaysItUsesTheHosts()
|
|
{
|
|
// Blank and absent are one state — CredentialSecret normalises them — and the row has to say which of
|
|
// the two things a blank box means, because "no username" and "the host's username" are not the same
|
|
// statement and only one of them is true.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddCredentialAsync(vault, "shared password", username: " ");
|
|
|
|
vault.Credentials.ShouldHaveSingleItem().Credential.Username.ShouldBeNull();
|
|
vault.Credentials[0].Description.ShouldBe("uses each host's own username");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ACredentialWithNoPassword_IsRefusedAndTheEditorStaysOpen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewCredentialCommand.Execute(null);
|
|
vault.CredentialEditorLabel = "prod deploy";
|
|
|
|
await vault.SaveCredentialCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("password");
|
|
vault.Credentials.ShouldBeEmpty();
|
|
vault.IsEditingCredential.ShouldBeTrue("the editor stays open so it can be filled in");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancellingTheCredentialEditor_LeavesNoPasswordBehindInIt()
|
|
{
|
|
// The same rule as the key editor, for the same reason: the editor holds the secret in a bound property
|
|
// for as long as it is open, and an abandoned editor that kept it would hand it to whatever opened next.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewCredentialCommand.Execute(null);
|
|
vault.CredentialEditorLabel = "prod deploy";
|
|
vault.CredentialEditorPassword = "ABANDONED";
|
|
|
|
vault.CancelCredentialEditCommand.Execute(null);
|
|
|
|
vault.IsEditingCredential.ShouldBeFalse();
|
|
vault.CredentialEditorPassword.ShouldBeEmpty();
|
|
vault.CredentialEditorLabel.ShouldBeEmpty();
|
|
vault.Credentials.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeletingACredential_RemovesItLocallyAndPushesTheTombstone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
await DeleteSelectedCredentialAsync(vault);
|
|
|
|
vault.Credentials.ShouldBeEmpty();
|
|
server.LiveRowCount.ShouldBe(0);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A reload must not <em>invent</em> a selection, which is a safety property rather than a tidiness one:
|
|
/// Delete acts on the selection, so a list that fell back to its first row would put a one-click deletion of
|
|
/// somebody's password behind a button they never aimed. It does keep an existing selection, exactly as the
|
|
/// host list does — losing it on every background sync would move the target out from under the user.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ReloadingKeepsACredentialSelectionButNeverInventsOne()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
|
|
// Saving selects what was just saved, which is wanted, and a reload has to leave it alone.
|
|
vault.SelectedCredential.ShouldNotBeNull();
|
|
await vault.LoadAsync(Token);
|
|
vault.SelectedCredential.ShouldNotBeNull();
|
|
|
|
// Nothing selected is the state Delete must find nothing in.
|
|
vault.SelectedCredential = null;
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.SelectedCredential.ShouldBeNull();
|
|
|
|
// Explicitly rather than through the helper: with nothing selected there is nothing to ask about,
|
|
// and the absence of a question is what proves the button found nothing to aim at.
|
|
vault.DeleteCredentialCommand.Execute(null);
|
|
vault.PendingDeletion.ShouldBeNull();
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Credentials.ShouldHaveSingleItem();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheVaultSummaryCountsCredentialsToo()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.Status.ShouldBe("1 host(s), 1 key(s), 1 credential(s) in Personal.");
|
|
|
|
// And a kind with nothing in it is left out rather than reported as zero.
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
await DeleteSelectedCredentialAsync(vault);
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// What decides whether the terminal column shows a password box at all. Three states and only one of them
|
|
/// wants typing, so this is the property that keeps a box from appearing on a host that has no use for one
|
|
/// — and keeps the sentence in its place from claiming the wrong reason.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePasswordBoxOnlyAppearsForAHostThatWillAskForOne()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
|
|
vault.SelectedHostAuthenticationNote.ShouldBeEmpty();
|
|
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
|
|
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
|
|
vault.SelectedHostAuthenticationNote.ShouldContain("SSH key");
|
|
|
|
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
|
|
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
|
|
vault.SelectedHostAuthenticationNote.ShouldContain("stored in your keychain");
|
|
}
|
|
|
|
// ---- Authenticating with a stored credential ----
|
|
|
|
[Fact]
|
|
public async Task AHostBoundToACredential_SendsItsPasswordAndItsUsername()
|
|
{
|
|
// Both halves, and the username is the half that was easy to lose: a credential's whole reason for
|
|
// existing is that it describes an account once, and sending its password under the host's username is
|
|
// wrong in a way the server only reports as "authentication failed".
|
|
//
|
|
// The credential's username is deliberately *not* the host's — the host is on "deploy" — because the
|
|
// two being equal is what makes this assertion pass under an implementation that reads the wrong one.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "svc-deploy");
|
|
|
|
var credentialId = vault.Credentials[0].EntityId;
|
|
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credentialId);
|
|
vault.Hosts[0].Authentication.ShouldBe("credential");
|
|
|
|
vault.ConnectPassword = "should-not-be-used";
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
|
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
|
|
request.Username.ShouldBe("svc-deploy", "the credential's account overrides the host's");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ACredentialWithNoUsername_LeavesTheHostsInPlace()
|
|
{
|
|
// The shared-password case: one password used under whatever account each machine knows you by. The
|
|
// fallback is what makes that expressible at all, and getting it backwards would send every connection
|
|
// to the same account.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddCredentialAsync(vault, "shared password", password: "s3cret");
|
|
|
|
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
|
request.Username.ShouldBe("deploy", "the host's own username, which ReadyToConnectAsync sets");
|
|
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHostWithNoUsernameOfItsOwn_IsUsableThroughACredentialThatCarriesOne()
|
|
{
|
|
// The refusal for a host with no username used to run before anything looked at the binding, which made
|
|
// a credential's username unreachable in exactly the case it was most useful: a host somebody never
|
|
// filled a username in for.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorUsername = string.Empty;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.Username.ShouldBeNull();
|
|
|
|
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");
|
|
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
ssh.Requests.ShouldHaveSingleItem().Username.ShouldBe("deploy");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHostWithNoUsernameAndNoCredential_IsStillRefusedAndSaysWhereToPutOne()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorUsername = string.Empty;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.ShouldBeEmpty();
|
|
vault.Status.ShouldContain("no username");
|
|
vault.Status.ShouldContain("credential", Case.Insensitive);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHostWhoseCredentialHasBeenDeleted_RefusesRatherThanFallingBackToTheTypedPassword()
|
|
{
|
|
// The key case's twin, and it has to be its own test: the two branches are separate code, and the one
|
|
// that was written second is the one nothing would have covered.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
|
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
await DeleteSelectedCredentialAsync(vault);
|
|
vault.Credentials.ShouldBeEmpty();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.ConnectPassword = "must-not-be-sent";
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
|
|
vault.Status.ShouldContain("credential that is not in this keychain");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAHostWhoseCredentialHasBeenDeleted_DoesNotQuietlyUnbindIt()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddCredentialAsync(vault, "prod deploy");
|
|
|
|
var credentialId = vault.Credentials[0].EntityId;
|
|
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
|
|
|
vault.SelectedCredential = vault.Credentials[0];
|
|
await DeleteSelectedCredentialAsync(vault);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
var selected = vault.EditorSelectedAuthentication.ShouldNotBeNull();
|
|
selected.EntityId.ShouldBe(credentialId);
|
|
selected.Kind.ShouldBe(
|
|
AuthenticationKind.Credential, "a missing credential must not come back as a missing key");
|
|
selected.Label.ShouldContain("no longer here");
|
|
|
|
vault.EditorPort = 2244;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.Port.ShouldBe(2244);
|
|
vault.Hosts[0].Host.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding");
|
|
}
|
|
|
|
// ---- Remembering a typed password ----
|
|
|
|
[Fact]
|
|
public async Task RememberingATypedPassword_BindsItToTheHostSoItIsNotAskedForAgain()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.RemembersConnectPassword.ShouldBeFalse("storing a password stays a decision");
|
|
|
|
vault.ConnectPassword = "s3cret";
|
|
vault.RemembersConnectPassword = true;
|
|
|
|
await ConnectAndRememberAsync(vault);
|
|
|
|
// An ordinary keychain credential, named after the host, and carrying no username of its own — the
|
|
// connection that just succeeded used the host's, and pinning a copy of it here would stop following
|
|
// the host.
|
|
var stored = vault.Credentials.ShouldHaveSingleItem();
|
|
stored.Label.ShouldBe("prod-db");
|
|
stored.Credential.Password.ShouldBe("s3cret");
|
|
stored.Credential.Username.ShouldBeNull();
|
|
|
|
var host = vault.Hosts.ShouldHaveSingleItem();
|
|
host.Host.CredentialId.ShouldBe(stored.EntityId);
|
|
host.Authentication.ShouldBe("credential");
|
|
|
|
// The box has nothing left to hold and nothing left to ask, and the tick does not carry over to
|
|
// whatever host is selected next.
|
|
vault.ConnectPassword.ShouldBeEmpty();
|
|
vault.RemembersConnectPassword.ShouldBeFalse();
|
|
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARememberedPassword_SurvivesTheServerAndIsSentOnTheNextConnection()
|
|
{
|
|
// The whole point of storing it in the vault rather than on this machine: it is a property of the
|
|
// host that reaches the other machines, not a box this one happens to remember filling in.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
// One renderer for both connections. The page's token is spent on the first attach, so a second
|
|
// FakeRenderer is answered with a 409 — which is the real renderer's behaviour too, and the reason
|
|
// nothing else in this suite connects twice.
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ConnectPassword = "s3cret";
|
|
vault.RemembersConnectPassword = true;
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.ConnectPassword.ShouldBeEmpty("nothing should need typing now");
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.Count.ShouldBe(2);
|
|
ssh.Requests[1].Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARefusedConnection_RemembersNothing()
|
|
{
|
|
// The failure this feature could most easily cause: a typo bound to the host, which then stops asking
|
|
// and cannot be connected to until somebody works out that the keychain is where the wrong password
|
|
// now lives. Only a handshake the remote accepted is worth keeping.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
ssh.Failure = new InvalidOperationException("authentication failed");
|
|
|
|
vault.ConnectPassword = "wrong";
|
|
vault.RemembersConnectPassword = true;
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
vault.Credentials.ShouldBeEmpty();
|
|
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
|
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConnectingWithoutTheTick_StoresNothing()
|
|
{
|
|
// The other half of the decision, and the reason the typed box still exists: a one-off password on a
|
|
// machine somebody will never open again must not end up synchronised to every device they own.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.ConnectPassword = "s3cret";
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
vault.Credentials.ShouldBeEmpty();
|
|
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
|
vault.ConnectPassword.ShouldBe("s3cret", "the box is left as it was typed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RememberingIsIgnoredForAHostThatDoesNotAskForAPassword()
|
|
{
|
|
// A tick left over from a host that did ask must not manufacture a credential out of a stored one's
|
|
// password — which is what reading the dialled secret without checking the binding would do.
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddCredentialAsync(vault, "prod deploy", password: "s3cret");
|
|
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.RemembersConnectPassword = true;
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
vault.Credentials.ShouldHaveSingleItem("nothing should have been added to the keychain");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The reason the picker is one control rather than two. <c>HostSecret.TryValidate</c> refuses a host naming
|
|
/// both a key and a credential, so two pickers would have been able to express the state and would have had
|
|
/// to reject it at save time; one picker cannot express it. This asserts the structural version of that —
|
|
/// that rebinding replaces rather than accumulates.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RebindingFromAKeyToACredential_ReplacesTheBindingRatherThanAddingToIt()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");
|
|
|
|
var keyId = vault.Keys[0].EntityId;
|
|
var credentialId = vault.Credentials[0].EntityId;
|
|
|
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
|
vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId);
|
|
|
|
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
|
|
|
var host = vault.Hosts.ShouldHaveSingleItem().Host;
|
|
host.CredentialId.ShouldBe(credentialId);
|
|
host.SshKeyId.ShouldBeNull("one picker means one binding");
|
|
host.TryValidate(out _).ShouldBeTrue();
|
|
|
|
// And back again, which is the direction that would leave a stale credential behind.
|
|
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
|
vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A key and a credential the user has named the same thing is the ordinary case, not a contrived one — a
|
|
/// key called <c>deploy</c> and the deploy account's password. Without the qualifier the picker offers two
|
|
/// identical rows that authenticate completely differently.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePickerDistinguishesAKeyAndACredentialWithTheSameName()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddCredentialAsync(vault, "deploy");
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
var named = vault.EditorAuthenticationChoices
|
|
.Where(choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal))
|
|
.ToList();
|
|
|
|
named.Count.ShouldBe(2);
|
|
named.Select(choice => choice.Qualifier).ShouldBe(["SSH key", "credential"]);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The credential editor guards the vault screen's own rail, exactly as the key editor does — but no
|
|
/// longer the host editor, which is a different screen and has nothing to lose by the rail moving.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheCredentialEditorGuardsTheKeychainScreensRail()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewCredentialCommand.Execute(null);
|
|
vault.CredentialEditorPassword = "half-typed";
|
|
|
|
// The host editor opens freely: it is the Hosts screen's own business now.
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.IsEditing.ShouldBeTrue("a different screen's editor is not this one's to refuse");
|
|
vault.IsEditingCredential.ShouldBeTrue("and opening it must not have closed the credential editor");
|
|
vault.CredentialEditorPassword.ShouldBe("half-typed");
|
|
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
vault.ShowsCredentials.ShouldBeTrue("the rail must not move away from an open vault-screen editor");
|
|
|
|
vault.CancelCredentialEditCommand.Execute(null);
|
|
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
vault.ShowsAll.ShouldBeTrue();
|
|
}
|
|
|
|
// ---- Pinned host keys ----
|
|
|
|
/// <remarks>
|
|
/// The list that did not exist. Trust was created by the connect prompt and withdrawn from one host's
|
|
/// editor, so a pin for a host that had since been deleted or re-addressed was unreachable from the
|
|
/// interface entirely — it went on refusing connections, and nothing in the application would admit it
|
|
/// was there.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePinnedKeyList_ShowsWhatWasApprovedAndWhatNothingUsesAnyMore()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("gone.internal", 22, "ssh-ed25519", "SHA256:another-key"), Token);
|
|
|
|
// Pushed, as the connect path pushes a pin the moment it is approved. Without this both rows would
|
|
// be badged "not synced", which is true and would drown out the badge this test is about.
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.KnownHostPins.Count.ShouldBe(2);
|
|
|
|
var dialled = vault.KnownHostPins
|
|
.Single(pin => string.Equals(pin.Host, "db.internal", StringComparison.Ordinal));
|
|
dialled.IsDialledByAHost.ShouldBeTrue("ReadyToConnectAsync's host is at db.internal:22");
|
|
dialled.Fingerprint.ShouldBe("SHA256:the-key", "in full, because that is what gets compared");
|
|
dialled.Badge.ShouldBeEmpty();
|
|
|
|
var orphan = vault.KnownHostPins
|
|
.Single(pin => string.Equals(pin.Host, "gone.internal", StringComparison.Ordinal));
|
|
orphan.IsDialledByAHost.ShouldBeFalse();
|
|
orphan.Badge.ShouldBe("no host uses this");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeletingAHost_LeavesItsPinBehindAndTheListSaysSo()
|
|
{
|
|
// The behaviour the debt was about, now visible instead of silent. Keeping the pin is right — the
|
|
// address may still be reached by something else, and trust is about the endpoint rather than the
|
|
// bookmark — so the fix was never to cascade the delete. It was to stop the leftover being invisible.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
|
|
await vault.LoadAsync(Token);
|
|
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
await DeleteSelectedHostAsync(vault);
|
|
|
|
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
|
|
"the pin outlives the host, and the list has to admit it");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForgettingAPinFromTheList_WithdrawsEveryKeyForThatAddress()
|
|
{
|
|
// One address, two algorithms, one decision. Somebody withdrawing trust from a machine has not
|
|
// decided to keep trusting one of its keys — and a pin left behind would go on being offered at the
|
|
// next handshake, which reads as a withdrawal that did not work.
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-ed25519-key"), Token);
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ecdsa-sha2-nistp256", "SHA256:the-ecdsa-key"), Token);
|
|
|
|
await vault.LoadAsync(Token);
|
|
vault.KnownHostPins.Count.ShouldBe(2);
|
|
|
|
vault.SelectedKnownHost = vault.KnownHostPins[0];
|
|
await vault.ForgetPinCommand.ExecuteAsync(null);
|
|
|
|
vault.KnownHostPins.ShouldBeEmpty();
|
|
vault.Status.ShouldContain("2 pinned key(s)");
|
|
|
|
// And it reached the vault, not just the snapshot: the next connection has to ask again.
|
|
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
|
|
|
// Pushed straight away, as trusting is — the other machines are the ones still refusing.
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForgettingWithNothingSelected_DoesNothing()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
|
|
await vault.LoadAsync(Token);
|
|
vault.SelectedKnownHost.ShouldBeNull("loading must not select a pin, because Forget acts on it");
|
|
|
|
await vault.ForgetPinCommand.ExecuteAsync(null);
|
|
|
|
vault.KnownHostPins.ShouldHaveSingleItem();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// v5c-3: fingerprints are public — operators publish theirs on purpose — so this is the one clipboard
|
|
/// copy on this screen that needs no confirmation and no refusal, unlike a private key's own
|
|
/// <c>CopyPublicKeyCommand</c>. In full, because a shortened fingerprint cannot be compared against what
|
|
/// was published.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task CopyingAPinsFingerprint_PutsTheFullFingerprintOnTheClipboard()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
|
|
await vault.LoadAsync(Token);
|
|
vault.SelectedKnownHost = vault.KnownHostPins.ShouldHaveSingleItem();
|
|
|
|
await vault.CopyPinFingerprintCommand.ExecuteAsync(null);
|
|
|
|
clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key");
|
|
}
|
|
|
|
/// <remarks>The v5c screen's own restyle over <see cref="KnownHostsViewModel"/> forwards the same command.</remarks>
|
|
[Fact]
|
|
public async Task CopyingAPinsFingerprintThroughTheKnownHostsScreen_ReachesTheVault()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
await vault.LoadAsync(Token);
|
|
|
|
var pins = shell.KnownHostsScreen.ShouldNotBeNull();
|
|
pins.Selected = pins.VisiblePins.ShouldHaveSingleItem();
|
|
|
|
await pins.CopyFingerprintCommand.ExecuteAsync(null);
|
|
|
|
clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The v5c header's own back arrow, reached through the same onBack delegate ImportViewModel's Cancel
|
|
/// button uses — see MainWindowViewModel.OnVaultChanged. Its destination is the Keychain screen this list
|
|
/// was pulled out of.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheKnownHostsScreensBackArrow_ReturnsToKeychain()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
|
|
shell.IsKnownHostsScreen.ShouldBeTrue();
|
|
|
|
var pins = shell.KnownHostsScreen.ShouldNotBeNull();
|
|
pins.BackCommand.Execute(null);
|
|
|
|
shell.IsKeychainScreen.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Pins used to be a category on the keychain screen. They are a destination of their own now, and this
|
|
/// is the seam that could silently come apart: the screen's view model is built from the vault in
|
|
/// <c>OnVaultChanged</c>, so a vault opened by any path other than the one this test takes would leave
|
|
/// the nav rail pointing at a null.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePinsScreenExistsForAsLongAsTheKeychainDoes()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var pins = shell.KnownHostsScreen.ShouldNotBeNull("unlocking builds it");
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
|
|
shell.IsKnownHostsShowing.ShouldBeTrue();
|
|
shell.IsKeychainShowing.ShouldBeFalse();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
await shell.Vault!.LoadAsync(Token);
|
|
|
|
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.KnownHostsScreen.ShouldBeNull("it goes with the keychain it was built from");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The workflow the screen exists for: an operator publishes a fingerprint and somebody wants to know
|
|
/// whether it is the one they approved. A filter that searched only host names would answer a different
|
|
/// question, so this is the assertion that keeps the fingerprint in the search.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePinsScreenFiltersByFingerprintAsWellAsByHost()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:aaaaaaaa"), Token);
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("web.internal", 22, "ssh-ed25519", "SHA256:bbbbbbbb"), Token);
|
|
|
|
await shell.Vault!.LoadAsync(Token);
|
|
|
|
var pins = shell.KnownHostsScreen!;
|
|
pins.VisiblePins.Count.ShouldBe(2);
|
|
|
|
pins.Filter = "bbbb";
|
|
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("web.internal");
|
|
|
|
pins.Filter = "db.";
|
|
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");
|
|
|
|
pins.Filter = "nothing matches this";
|
|
pins.VisiblePins.ShouldBeEmpty();
|
|
pins.EmptyMessage.ShouldContain("matches that", Case.Insensitive);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Forgetting is forwarded to the vault's command, which is the one wired into the reload and the push.
|
|
/// What this covers is the forwarding: that the screen's own selection reaches it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ForgettingFromThePinsScreen_WithdrawsTheTrust()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
await knownHosts.TrustAsync(
|
|
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
|
await shell.Vault!.LoadAsync(Token);
|
|
|
|
var pins = shell.KnownHostsScreen!;
|
|
pins.Selected = pins.VisiblePins[0];
|
|
|
|
await pins.ForgetSelectedCommand.ExecuteAsync(null);
|
|
|
|
pins.VisiblePins.ShouldBeEmpty();
|
|
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
|
|
}
|
|
|
|
// ---- Generating a key ----
|
|
|
|
/// <remarks>
|
|
/// The property that keeps this feature from being a second way to write a key: generating fills the
|
|
/// editor and stops. Everything after that — validation, encoding, the outbox, the push — is the path a
|
|
/// pasted key already takes, and SAVE is still the only thing that writes.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task GeneratingAKey_FillsTheEditorAndStoresNothing()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewGeneratedKeyCommand.Execute(null);
|
|
vault.IsGeneratingKey.ShouldBeTrue();
|
|
vault.GenerateComment = "deploy@laptop";
|
|
|
|
await vault.GenerateKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.IsGeneratingKey.ShouldBeFalse();
|
|
vault.IsEditingKey.ShouldBeTrue("what it made lands in the editor, unsaved");
|
|
|
|
vault.KeyEditorLabel.ShouldBe("deploy@laptop");
|
|
vault.KeyEditorPrivateKey.ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
|
|
vault.KeyEditorPublicKey.ShouldStartWith("ssh-ed25519 ");
|
|
vault.KeyEditorPublicKey.ShouldEndWith("deploy@laptop");
|
|
|
|
vault.Keys.ShouldBeEmpty("nothing is stored until SAVE");
|
|
vault.PendingChanges.ShouldBe(0);
|
|
vault.Status.ShouldContain("SAVE");
|
|
|
|
// And then it saves through the ordinary path, which is the other half of the claim.
|
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.Keys.ShouldHaveSingleItem().Label.ShouldBe("deploy@laptop");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancellingTheGenerateForm_MakesNothing()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewGeneratedKeyCommand.Execute(null);
|
|
vault.CancelGenerateKeyCommand.Execute(null);
|
|
|
|
vault.IsGeneratingKey.ShouldBeFalse();
|
|
vault.IsEditingKey.ShouldBeFalse();
|
|
vault.Keys.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The public half and only the public half. There is deliberately no command for the other one — a
|
|
/// private key on a clipboard is a private key in every application on the machine — so what this pins
|
|
/// is that the one thing installing a key needs does reach the clipboard.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task CopyingAPublicKey_PutsThePublicHalfOnTheClipboard()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewGeneratedKeyCommand.Execute(null);
|
|
await vault.GenerateKeyCommand.ExecuteAsync(null);
|
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
|
|
|
vault.Section = VaultSection.Keys;
|
|
vault.SelectedVaultItem = vault.VaultItems[0];
|
|
vault.SelectedItemIsKey.ShouldBeTrue();
|
|
|
|
await vault.CopyPublicKeyCommand.ExecuteAsync(null);
|
|
|
|
var copied = clipboard.ShouldHaveSingleItem();
|
|
|
|
copied.ShouldStartWith("ssh-");
|
|
copied.ShouldNotContain("PRIVATE KEY");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ◆ <b>The one secret this application deliberately offers to the clipboard.</b> The recovery code
|
|
/// exists for one screen, is stored nowhere and has to reach a password manager, so the clipboard is
|
|
/// where it is going whatever the interface does — the only question is whether the interface helps or
|
|
/// leaves somebody transcribing it, or photographing a screen that blocks screenshots.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task CopyingTheRecoveryCode_PutsItOnTheClipboardAndSaysWhereToPutIt()
|
|
{
|
|
await EnrolledAsync();
|
|
|
|
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
|
|
|
|
var code = shell.RecoveryCode.ShouldNotBeNull();
|
|
|
|
await shell.CopyRecoveryCodeCommand.ExecuteAsync(null);
|
|
|
|
clipboard.ShouldHaveSingleItem().ShouldBe(code);
|
|
|
|
// The sentence matters as much as the copy. A clipboard is a staging post rather than a home, and
|
|
// this screen is the only place the code exists — somebody who copies it and does nothing has not
|
|
// saved it.
|
|
shell.StatusMessage.ShouldContain("password manager");
|
|
shell.StatusMessage.ShouldContain("replaces it");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other branch, and it needs a shell built without a clipboard because that is precisely the
|
|
/// condition — the view model reads the delegate's absence, not an empty result. It must say so rather
|
|
/// than leaving a button that appears to have worked: a recovery code somebody believes is on their
|
|
/// clipboard is a recovery code they will not write down.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task CopyingTheRecoveryCode_WithNoClipboard_SaysSoRatherThanSeemingToWork()
|
|
{
|
|
var bare = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
deviceKeys,
|
|
SignInAsync,
|
|
TimeProvider.System,
|
|
ssh,
|
|
CheapProfile,
|
|
ResumeAsync);
|
|
|
|
await using (bare.ConfigureAwait(false))
|
|
{
|
|
bare.RecoveryCode = "correct horse battery staple";
|
|
|
|
await bare.CopyRecoveryCodeCommand.ExecuteAsync(null);
|
|
|
|
bare.StatusMessage.ShouldContain("no clipboard", Case.Insensitive);
|
|
clipboard.ShouldBeEmpty("the other shell's clipboard must not have been written to either");
|
|
}
|
|
}
|
|
|
|
// ---- Importing ssh_config ----
|
|
|
|
/// <remarks>
|
|
/// The whole of the import, from a file on disk to hosts on the server. What it establishes beyond the
|
|
/// parser's own suite is the half that suite cannot reach: that scanning writes nothing, that importing
|
|
/// goes through the ordinary create-and-push path, and that a host already in the keychain arrives
|
|
/// unticked rather than being silently duplicated.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
var sshDirectory = Path.Combine(directory, "ssh");
|
|
Directory.CreateDirectory(sshDirectory);
|
|
|
|
// db.internal:deploy is what AddHostAsync creates, so the first entry is a host already held.
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(sshDirectory, "config"),
|
|
"""
|
|
Host already-here
|
|
HostName db.internal
|
|
User deploy
|
|
|
|
Host web-01
|
|
HostName web-01.internal
|
|
User deploy
|
|
Port 2222
|
|
""",
|
|
Token);
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.Rows.Count.ShouldBe(2);
|
|
vault.Hosts.Count.ShouldBe(1, "scanning stores nothing");
|
|
|
|
var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal));
|
|
known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has");
|
|
known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default");
|
|
|
|
import.Rows
|
|
.Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
|
|
.IsSelected.ShouldBeTrue();
|
|
|
|
await import.ImportCommand.ExecuteAsync(null);
|
|
|
|
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
|
imported.Address.ShouldBe("deploy@web-01.internal:2222");
|
|
|
|
vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored");
|
|
|
|
// Through the ordinary path, which is the point of routing it through the vault: it reached the
|
|
// server without anything pressing Sync.
|
|
server.LiveRowCount.ShouldBe(2);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ◆ <b>The tick, and the half of it that matters is what happens with it off.</b> Reading a private key
|
|
/// out of a home directory is the act this product exists to make deliberate, so the default has to be a
|
|
/// default nobody arrives at by accident — and a scan that read keys in order to describe them would
|
|
/// have already done the thing the tick gates. So this asserts twice over: nothing is bound, and the
|
|
/// report that names every file it opened is empty.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ImportingWithoutTheKeyTick_RecordsThePathAndReadsNothing()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var sshDirectory = KeyedConfigDirectory();
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.HasKeyFiles.ShouldBeTrue("the tick is drawn only where there is a key to read");
|
|
import.ImportsKeys.ShouldBeFalse("and it starts off");
|
|
|
|
await import.ImportCommand.ExecuteAsync(null);
|
|
|
|
vault.Keys.ShouldBeEmpty("nothing reached into ~/.ssh");
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
|
|
import.KeyReport.ShouldBeEmpty("and no file was opened to report on");
|
|
|
|
// The path is still recorded, which is what the import did before the tick existed.
|
|
vault.Hosts[0].Host.Notes.ShouldContain("id_ed25519");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other side of it: with the tick on, the key is read, stored encrypted in the vault and bound to
|
|
/// the host — which is the difference between an import whose result connects and one whose every host
|
|
/// asks for a password.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ImportingWithTheKeyTick_StoresTheKeyAndBindsTheHostToIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var sshDirectory = KeyedConfigDirectory();
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.ImportsKeys = true;
|
|
|
|
// The row says what it will do, which is the only place a person sees the tick's effect per host.
|
|
import.Rows.ShouldHaveSingleItem().Authentication.ShouldContain("imported");
|
|
|
|
await import.ImportCommand.ExecuteAsync(null);
|
|
|
|
var key = vault.Keys.ShouldHaveSingleItem();
|
|
|
|
key.Key.PrivateKeyPem.ShouldContain("PRIVATE KEY");
|
|
key.Key.PublicKey.ShouldBe("ssh-ed25519 AAAAC3Nz nobody@example");
|
|
key.Key.Notes.ShouldContain("id_ed25519");
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(key.EntityId);
|
|
vault.Hosts[0].Authentication.ShouldBe("key");
|
|
|
|
import.KeyReport.ShouldHaveSingleItem().ShouldContain("imported");
|
|
import.Status.ShouldContain("1 private key");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One vault key per file, however many entries named it — the shape a real <c>ssh_config</c> actually
|
|
/// has. Twelve copies of one private key would be twelve things to rotate and eleven to forget, which is
|
|
/// the argument <c>HostSecret.SshKeyId</c> already makes for referencing a key rather than embedding it.
|
|
/// </para>
|
|
/// <para>
|
|
/// The second half is the one that would go unnoticed: importing the same config twice must bind to the
|
|
/// key that is already there rather than storing a second copy of it. Asserted by running the whole
|
|
/// import again over a keychain that now holds one.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TwoHostsNamingOneKeyFile_ShareOneStoredKeyAndARepeatAddsNone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var sshDirectory = KeyedConfigDirectory("""
|
|
Host web-01
|
|
HostName web-01.internal
|
|
User deploy
|
|
IdentityFile ~KEY~
|
|
|
|
Host web-02
|
|
HostName web-02.internal
|
|
User deploy
|
|
IdentityFile ~KEY~
|
|
""");
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.ImportsKeys = true;
|
|
|
|
await import.ImportCommand.ExecuteAsync(null);
|
|
|
|
var key = vault.Keys.ShouldHaveSingleItem("one file is one key");
|
|
|
|
vault.Hosts.Count.ShouldBe(2);
|
|
vault.Hosts.ShouldAllBe(row => row.Host.SshKeyId == key.EntityId);
|
|
import.Status.ShouldContain("1 private key");
|
|
|
|
// Again, over a keychain that already holds it. The hosts duplicate — a second bookmark for one
|
|
// machine is allowed and takes a click — and the key must not.
|
|
foreach (var row in import.Rows)
|
|
{
|
|
row.IsSelected = true;
|
|
}
|
|
|
|
await import.ImportCommand.ExecuteAsync(null);
|
|
|
|
vault.Keys.ShouldHaveSingleItem("the material was already here, so it was bound to rather than stored");
|
|
import.Status.ShouldNotContain("private key", Case.Insensitive);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A passphrase is the one thing about a key file that is not in the key file, so an encrypted one comes
|
|
/// in without it and would fail at connect time with SSH.NET's own message. The import says so instead —
|
|
/// naming the file, because a config with a dozen keys has a dozen candidates for which one it was.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnEncryptedKeyIsImportedAndSaidToBeEncrypted()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var sshDirectory = KeyedConfigDirectory();
|
|
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(sshDirectory, "id_ed25519"),
|
|
"-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nQUJD\n"
|
|
+ "-----END RSA PRIVATE KEY-----\n",
|
|
Token);
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.ImportsKeys = true;
|
|
|
|
await import.ImportCommand.ExecuteAsync(null);
|
|
|
|
vault.Keys.ShouldHaveSingleItem().Key.Passphrase.ShouldBeNull("nothing on disk says what it is");
|
|
|
|
var reported = import.KeyReport.ShouldHaveSingleItem();
|
|
|
|
reported.ShouldContain("passphrase");
|
|
reported.ShouldContain("id_ed25519");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A config carried from another machine names keys that are not on this one, and that is the ordinary
|
|
/// case rather than an error. The host still imports — unbound, exactly as it would have without the
|
|
/// tick — and the report says which file was missing, because otherwise the only symptom is a host that
|
|
/// asks for a password on a screen that said it would not.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AKeyFileThatIsNotThere_LeavesTheHostImportedAndUnbound()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var sshDirectory = KeyedConfigDirectory();
|
|
|
|
File.Delete(Path.Combine(sshDirectory, "id_ed25519"));
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.ImportsKeys = true;
|
|
|
|
await import.ImportCommand.ExecuteAsync(null);
|
|
|
|
vault.Keys.ShouldBeEmpty();
|
|
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
|
|
|
|
import.KeyReport.ShouldHaveSingleItem().ShouldContain("no such file");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A <c>~/.ssh</c> holding a config that names a key, and the key beside it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The key path is written into the config as an absolute one, which is what
|
|
/// <c>SshConfigResolver.ExpandHome</c> produces for a real <c>~/.ssh/id_ed25519</c> — the tests cannot
|
|
/// use a tilde, since that would resolve against the profile of whoever is running them.
|
|
/// </remarks>
|
|
private string KeyedConfigDirectory(string? config = null)
|
|
{
|
|
var sshDirectory = Path.Combine(directory, $"ssh-{Guid.CreateVersion7():N}");
|
|
|
|
Directory.CreateDirectory(sshDirectory);
|
|
|
|
var keyPath = Path.Combine(sshDirectory, "id_ed25519");
|
|
|
|
File.WriteAllText(keyPath, PrivateKey("QUJDRA"));
|
|
File.WriteAllText(keyPath + ".pub", "ssh-ed25519 AAAAC3Nz nobody@example\n");
|
|
|
|
var text = config ?? """
|
|
Host web-01
|
|
HostName web-01.internal
|
|
User deploy
|
|
IdentityFile ~KEY~
|
|
""";
|
|
|
|
File.WriteAllText(
|
|
Path.Combine(sshDirectory, "config"),
|
|
text.Replace("~KEY~", keyPath, StringComparison.Ordinal));
|
|
|
|
return sshDirectory;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImportingWithNoConfigFile_SaysSoRatherThanFailing()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var import = new ImportViewModel(
|
|
shell.Vault!,
|
|
new SshConfigLocator(Path.Combine(directory, "nothing-here")));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.Rows.ShouldBeEmpty();
|
|
import.Status.ShouldContain("no", Case.Insensitive);
|
|
}
|
|
|
|
// ---- v5c-3: the WHAT THIS MEANS chip, tick-all, and the footer's own facts ----
|
|
|
|
/// <remarks>
|
|
/// The three real states a row can be in, and nothing else: a skipped <c>Host</c> pattern never becomes a
|
|
/// row at all (see <c>SshConfigImport.SkippedPatterns</c>), so there is no fourth, invented "skipped" chip
|
|
/// to test for. A warned row wins over "already here" — see <c>ImportRowViewModel.Meaning</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheImportersMeaningChipsMapTheRealRowStatesHonestly()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
var sshDirectory = Path.Combine(directory, $"ssh-meaning-{Guid.CreateVersion7():N}");
|
|
Directory.CreateDirectory(sshDirectory);
|
|
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(sshDirectory, "config"),
|
|
"""
|
|
Host already-here
|
|
HostName db.internal
|
|
User deploy
|
|
|
|
Host bastion
|
|
HostName bastion.internal
|
|
User ops
|
|
ProxyCommand nc %h %p
|
|
|
|
Host fresh
|
|
HostName fresh.internal
|
|
User deploy
|
|
""",
|
|
Token);
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.Rows.Count.ShouldBe(3);
|
|
|
|
var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal));
|
|
known.IsMeaningExisting.ShouldBeTrue();
|
|
known.IsMeaningNew.ShouldBeFalse();
|
|
known.IsMeaningWarned.ShouldBeFalse();
|
|
known.Meaning.ShouldBe("already here");
|
|
|
|
var warned = import.Rows.Single(row => string.Equals(row.Alias, "bastion", StringComparison.Ordinal));
|
|
warned.IsMeaningWarned.ShouldBeTrue();
|
|
warned.IsMeaningNew.ShouldBeFalse();
|
|
warned.IsMeaningExisting.ShouldBeFalse();
|
|
// The warned chip carries the row's own real reason.
|
|
warned.Meaning.ShouldContain("ProxyCommand");
|
|
|
|
var fresh = import.Rows.Single(row => string.Equals(row.Alias, "fresh", StringComparison.Ordinal));
|
|
fresh.IsMeaningNew.ShouldBeTrue();
|
|
fresh.IsMeaningExisting.ShouldBeFalse();
|
|
fresh.IsMeaningWarned.ShouldBeFalse();
|
|
fresh.Meaning.ShouldBe("new host");
|
|
}
|
|
|
|
/// <remarks>The header's own tick-all box, over <see cref="ImportViewModel.ToggleAllCommand"/>.</remarks>
|
|
[Fact]
|
|
public async Task TickingAllTogglesEveryRowAndTheHeaderTickReflectsIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var sshDirectory = Path.Combine(directory, $"ssh-tickall-{Guid.CreateVersion7():N}");
|
|
Directory.CreateDirectory(sshDirectory);
|
|
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(sshDirectory, "config"),
|
|
"""
|
|
Host a
|
|
HostName a.internal
|
|
|
|
Host b
|
|
HostName b.internal
|
|
""",
|
|
Token);
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.AllTicked.ShouldBeTrue("both are new hosts, which start ticked");
|
|
|
|
import.Rows[0].IsSelected = false;
|
|
import.NoteSelectionChanged();
|
|
|
|
import.AllTicked.ShouldBeFalse();
|
|
|
|
import.ToggleAllCommand.Execute(null);
|
|
|
|
import.AllTicked.ShouldBeTrue("fewer than all ticked toggles everything on");
|
|
import.Rows.ShouldAllBe(row => row.IsSelected);
|
|
|
|
import.ToggleAllCommand.Execute(null);
|
|
|
|
import.AllTicked.ShouldBeFalse();
|
|
import.Rows.ShouldAllBe(row => !row.IsSelected);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The key-material opt-in card's own always-visible sentence: a real count of hosts naming a key file,
|
|
/// the real directory, and the same "nothing is read until Import is pressed" claim verified against
|
|
/// <see cref="SshConfigLocator.ReadIdentity"/> only ever being called from <c>ImportAsync</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheKeyMaterialCardsIntroSentence_NamesTheRealCountAndDirectory()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var sshDirectory = KeyedConfigDirectory();
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.KeyMaterialIntro.ShouldContain("1 host names");
|
|
import.KeyMaterialIntro.ShouldContain(sshDirectory);
|
|
import.KeyMaterialIntro.ShouldContain(
|
|
"nothing is read until Import is pressed", Case.Insensitive);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheFooterSummary_NamesTheRealSelectionCountAndVault()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var sshDirectory = Path.Combine(directory, $"ssh-summary-{Guid.CreateVersion7():N}");
|
|
Directory.CreateDirectory(sshDirectory);
|
|
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(sshDirectory, "config"), "Host a\n HostName a.internal\n", Token);
|
|
|
|
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
|
|
await import.ScanCommand.ExecuteAsync(null);
|
|
|
|
import.SelectionSummary.ShouldBe($"1 of 1 entry selected · saving to {vault.VaultName}");
|
|
}
|
|
|
|
// ---- 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>
|
|
/// v5c-2: the settings Groups page's "No group" footer row. Counts a host whose group has never been set
|
|
/// and one whose group id dangles (deleted from under it) the same way — both are "ungrouped" to a person
|
|
/// looking at the list, per the reading <c>FlattenIntoSections</c> already gives the sidebar's own
|
|
/// heading, and <c>UngroupedHostCount</c> has to agree with it rather than invent a second definition.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task UngroupedHostCount_CountsHostsWithNoGroupAndHostsWhoseGroupHasGone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "stage-web");
|
|
await AddHostAsync(vault, "bastion");
|
|
await AddGroupAsync(vault, "production");
|
|
|
|
vault.UngroupedHostCount.ShouldBe(3, "no host has been filed under the new group yet");
|
|
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.UngroupedHostCount.ShouldBe(2, "one host now belongs to a real group");
|
|
|
|
var group = vault.Groups.Single();
|
|
vault.DeleteGroupCommand.Execute(group);
|
|
vault.PendingDeletion.ShouldNotBeNull();
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.UngroupedHostCount.ShouldBe(
|
|
3, "a host whose group was deleted falls back to ungrouped rather than vanishing from the count");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// An empty group keeps its heading; a group emptied by the filter does not. The first is a folder
|
|
/// somebody made and can put things in, the second is an absence of search results — and a heading with
|
|
/// nothing under it reads as a group that has lost its contents.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AGroupEmptiedByTheFilter_LosesItsHeading()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await AddGroupAsync(vault, "staging");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
Headings(vault).ShouldBe(["production", "staging"], "an empty group keeps its heading");
|
|
|
|
vault.HostFilter = "nothing matches this";
|
|
|
|
Headings(vault).ShouldBe(["production", "staging"]);
|
|
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FoldingAGroupAwayHidesItsHostsAndSurvivesAReload()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
|
|
|
|
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty("the group is folded away");
|
|
|
|
// Folded state is held by group id rather than on the row, because a background sync rebuilds every
|
|
// row once a minute and a flag on one would be forgotten the first time it did.
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty("and a reload does not unfold it");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The heading is a row in the same <c>ListBox</c> as the hosts, so the control will select it. Nothing
|
|
/// else in the application acts on a heading — CONNECT, EDIT and DELETE all read the host selection — so
|
|
/// clicking one has to leave that selection exactly where it was.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SelectingAHeading_LeavesTheHostSelectionAlone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
var host = vault.Hosts.Single();
|
|
vault.SelectedHost = host;
|
|
|
|
vault.SelectedSidebarRow = vault.SidebarRows.OfType<SidebarGroupHeader>().First();
|
|
|
|
vault.SelectedHost.ShouldBeSameAs(host);
|
|
vault.SelectedSidebarRow.ShouldBeSameAs(host, "the heading hands the highlight straight back");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ v5: dragging a host card onto a group card is gone, so this files through the editor instead — see
|
|
/// <see cref="FileAsync"/> — which is the one thing every head can still do. What survives to measure is
|
|
/// <see cref="VaultViewModel.Matches"/> and <see cref="VaultViewModel.RebuildVisibleHosts"/>'s own
|
|
/// "one level of the tree" filtering, fed by <see cref="VaultViewModel.GroupFilter"/> directly now that
|
|
/// nothing sets it through a command — see that property's own remarks.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The card goes into the group and off the level it was filed from</b>, which is the whole of what
|
|
/// filing looks like on a grid that holds one level of the tree — the host is inside the card it was
|
|
/// filed under now, and that is where it is drawn. It used to stay put and gain a chip.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
|
|
var group = vault.Groups.Single().EntityId;
|
|
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
|
|
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
|
|
|
|
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
|
|
// tree flattened, so the host is still in it.
|
|
vault.SidebarRows.OfType<SidebarGroupHeader>()
|
|
.Single(header => header.GroupId == group)
|
|
.Count.ShouldBe(1);
|
|
|
|
// The name is on the card, which is what the desktop's grid draws instead of that heading, and what
|
|
// says which group a searched-up card came out of.
|
|
vault.Hosts.Single().GroupLabel.ShouldBe("production");
|
|
vault.Hosts.Single().HasGroup.ShouldBeTrue();
|
|
|
|
// Opening the group is where it went, and the way to it.
|
|
vault.GroupFilter = vault.Groups.Single();
|
|
|
|
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
|
|
|
// And back out again.
|
|
vault.GroupFilter = null;
|
|
|
|
vault.SelectedHost = vault.Hosts.Single();
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
|
choice => choice.EntityId is null);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
|
|
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
|
|
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The grid holds one level of the tree, the way a directory pane holds one directory.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A host filed under a group is inside that group and is not also on the screen the group's card sits
|
|
/// on. While it was both, opening a group could only ever take hosts away — the level above already had
|
|
/// all of them — and the cards were headings rather than places. The phone's list is the deliberate
|
|
/// exception and is asserted here beside it: it draws the whole tree flat under headings, because it has
|
|
/// no cards to open and nowhere to open one into.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AHostFiledUnderAGroup_IsDrawnInsideItAndNotAtTheLevelAbove()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "stage-web");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.VisibleHosts.Select(row => row.Label)
|
|
.ShouldBe(["stage-web"], "the outermost level is what nothing has been filed into");
|
|
|
|
vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
|
|
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
|
|
|
|
vault.GroupFilter = vault.Groups.Single();
|
|
|
|
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
|
|
|
|
vault.GroupFilter = null;
|
|
|
|
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The one thing on the screen that crosses a group boundary, and it has to be one. A search that looked
|
|
/// only at the level it was typed on would answer "no host matches that" about a machine this keychain
|
|
/// has got — and finding a machine without first remembering where it was filed is most of what the box
|
|
/// is for. Typed at the outermost level it reaches everything; typed inside a group it reaches that
|
|
/// group and what is under it, which is the same rule read from where you are standing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheFindBox_SearchesInsideTheGroupsRatherThanOnlyTheLevelOnScreen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "stage-web");
|
|
await AddGroupAsync(vault, "estate");
|
|
await AddGroupAsync(vault, "production");
|
|
await SetGroupParentAsync(vault, "production", "estate");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.VisibleHosts.ShouldNotContain(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
|
|
|
vault.HostFilter = "prod-db";
|
|
|
|
vault.VisibleHosts.ShouldHaveSingleItem().Label
|
|
.ShouldBe("prod-db", "two levels down, and the box reaches it");
|
|
|
|
// And inside a group it is that group's subtree: estate holds production, which holds the host.
|
|
vault.GroupFilter =
|
|
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
|
|
|
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
|
|
|
vault.HostFilter = "stage";
|
|
|
|
vault.VisibleHosts.ShouldBeEmpty("stage-web is outside estate, so this search does not reach it");
|
|
vault.NoVisibleHostsMessage.ShouldContain("ALL HOSTS", Case.Sensitive, "and it says how to widen it");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A grid with no cards in it has to say why, and "they are all filed away" is a different sentence from
|
|
/// "there are none" and from "nothing matches what you typed". It is the answer the level-at-a-time grid
|
|
/// made reachable: before it, a keychain with hosts in it always drew some.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AKeychainWhoseHostsAreAllFiled_SaysSoRatherThanLookingEmpty()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.HasVisibleHosts.ShouldBeFalse();
|
|
vault.NoVisibleHostsMessage.ShouldContain("filed under a group");
|
|
vault.NoVisibleHostsMessage.ShouldNotContain(
|
|
"No hosts yet", Case.Sensitive, "telling somebody with hosts to add their first one answers nothing");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
|
|
/// inside a group and filed under none would vanish from the screen it was created on, which is the
|
|
/// papercut that comes free with a grid holding one level — so the editor opens on the group
|
|
/// <see cref="VaultViewModel.GroupTarget"/> names, and the picker shows it before anything is saved.
|
|
/// <c>GroupFilter</c> is written directly rather than through the deleted <c>OpenGroupCommand</c> — see
|
|
/// that property's own remarks.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddGroupAsync(vault, "production");
|
|
|
|
vault.GroupFilter = vault.Groups.Single();
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
|
|
vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("production");
|
|
|
|
vault.EditorLabel = "prod-db";
|
|
vault.EditorHostname = "db.internal";
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.VisibleHosts.ShouldHaveSingleItem().Label
|
|
.ShouldBe("prod-db", "so it is on the screen it was made on");
|
|
|
|
vault.NewGroupCommand.Execute(null);
|
|
|
|
vault.GroupEditorSelectedParent.ShouldNotBeNull().Label
|
|
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Deleting a group leaves the machines under it alone <em>and</em> stops them naming it. It used to do
|
|
/// only the first: the reference was left dangling and the list resolved it to nothing, which looked
|
|
/// identical and cost no writes. The tick is what changed that — a deletion that can take the hosts with
|
|
/// it has to be a deletion that knows which hosts it means, and once it knows, leaving them holding the
|
|
/// id of something that has gone is a state kept for no reason.
|
|
/// </para>
|
|
/// <para>
|
|
/// The tick is deliberately not touched here, which is the point of the assertions: the default answer
|
|
/// is the one that keeps the machines.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingAGroup_UnfilesItsHostsRatherThanLeavingThemNamingIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
|
|
|
var question = vault.PendingDeletion.ShouldNotBeNull();
|
|
|
|
question.Usage
|
|
.ShouldContain("1 host", Case.Sensitive, "the count is what makes the question worth reading");
|
|
|
|
question.HasChoice.ShouldBeTrue("a group with a host under it has a second question");
|
|
vault.DeletionTakesTheHostsToo.ShouldBeFalse("the safe answer is the one nobody has to choose");
|
|
|
|
// The pass that follows every write on this screen reports what it moved and supersedes the
|
|
// confirmation, for a deletion as much as for a save — so it is made to fail, and what the sentence
|
|
// says is asserted in the state where somebody actually reads it.
|
|
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Groups.ShouldBeEmpty();
|
|
vault.HasGroups.ShouldBeFalse();
|
|
|
|
vault.Hosts.Single().Host.GroupId.ShouldBeNull(vault.Status);
|
|
vault.Hosts.Single().GroupLabel.ShouldBeEmpty();
|
|
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
|
|
|
|
vault.Status.ShouldContain("UNGROUPED", Case.Sensitive);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other answer, and the reason the question is asked at all: a group is sometimes a heading being
|
|
/// tidied away and sometimes a project that has been decommissioned, and nothing in the view model can
|
|
/// tell which of the two it is looking at.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingAGroupWithTheTickSet_TakesItsHostsWithIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "prod-web");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
|
|
|
vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
|
|
vault.DeletionTakesTheHostsToo = true;
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Groups.ShouldBeEmpty();
|
|
|
|
// Only the machine that was filed under it. A deletion aimed at a heading must not reach the hosts
|
|
// that were never on it.
|
|
vault.Hosts.Select(row => row.Label).ShouldBe(["prod-web"], vault.Status);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The answer is not carried from one question to the next. A tick left standing would delete the next
|
|
/// group's machines on the strength of a decision about the last one's, and there is no undo on either
|
|
/// side of that.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AskingAboutASecondGroup_StartsFromKeepingItsHosts()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await AddGroupAsync(vault, "staging");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
|
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal)));
|
|
vault.DeletionTakesTheHostsToo = true;
|
|
vault.CancelDeleteCommand.Execute(null);
|
|
|
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
|
|
row => string.Equals(row.Label, "staging", StringComparison.Ordinal)));
|
|
|
|
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Unfiling rewrites every host under the heading, so it is refused with a host editor open for the
|
|
/// reason a drop onto a group card is: rewriting the saved host under a half-typed edit of it would be a
|
|
/// save nobody asked for, and one they could then not cancel. Deleting a single host is not refused,
|
|
/// because that one writes nothing to a form anybody is looking at.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingAGroupWhileTheHostEditorIsOpen_IsRefused()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.SelectedHost = vault.Hosts.Single();
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorLabel = "half-typed";
|
|
|
|
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
|
|
|
vault.PendingDeletion.ShouldBeNull("the question was never put");
|
|
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
|
|
vault.Status.ShouldContain("editing");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The picker keeps a placeholder entry for a group the vault does not have, exactly as the
|
|
/// authentication picker does for a deleted key. Without it the picker would open on "No group" and
|
|
/// somebody editing the host's port would unfile it by saving.
|
|
/// </para>
|
|
/// <para>
|
|
/// The dangling id is imported rather than produced by deleting the group, and that is a consequence of
|
|
/// the change above rather than a contrivance: a group deleted <em>here</em> now unfiles its hosts on the
|
|
/// way out, so the only way a host still names one is that the group went on another machine and this
|
|
/// client has yet to be told — which is exactly what a host arriving with an id nothing resolves is.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EditingAHostWhoseGroupIsGone_DoesNotUnfileItBySaving()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
var groupId = Guid.CreateVersion7();
|
|
|
|
await vault.ImportHostsAsync(
|
|
[
|
|
new ImportedHostRequest(
|
|
new HostSecret { Label = "prod-db", Hostname = "db.internal", GroupId = groupId },
|
|
Key: null,
|
|
KeyPath: null),
|
|
],
|
|
Token);
|
|
|
|
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
|
|
|
|
vault.SelectedHost = vault.Hosts.Single();
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorSelectedGroup.ShouldNotBeNull().EntityId.ShouldBe(groupId);
|
|
|
|
vault.EditorPort = 2222;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId, "an unrelated edit must not unfile the host");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RenamingAGroup_RenamesItsHeading()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.EditGroupCommand.Execute(vault.Groups.Single());
|
|
|
|
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.EditGroupCommand.Execute(vault.Groups.Single(
|
|
row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
|
|
|
|
vault.GroupEditorParentChoices
|
|
.Select(choice => choice.Label)
|
|
.ShouldBe(["No group"], "estate cannot be its own parent, and production already sits under it");
|
|
}
|
|
|
|
// ---- What the phone's + drives ----
|
|
//
|
|
// The phone's own pixels are not measurable here and cannot be: the layout suite is net10.0 and
|
|
// DodoSSH.Client.Android is net10.0-android, so its views are unreachable by construction, and Avalonia's
|
|
// application is a process global so a second head cannot share this process either. What IS shared is
|
|
// every property and command the sheet and its two editors bind to, which is all of the behaviour — the
|
|
// markup only decides where it is drawn. So the flow is tested here and the rectangles go to
|
|
// docs/manual-checks.md, which is where this project already sends what it cannot assert.
|
|
|
|
[Fact]
|
|
public async Task TheAddSheet_OffersTwoThingsAndOpensNeitherUntilOneIsChosen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.OpenAddSheetCommand.Execute(null);
|
|
|
|
vault.IsAddSheetOpen.ShouldBeTrue();
|
|
vault.AnEditorIsOpen.ShouldBeTrue("the + hides while anything is over the list");
|
|
vault.IsEditing.ShouldBeFalse();
|
|
vault.IsEditingGroup.ShouldBeFalse();
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
|
|
vault.IsAddSheetOpen.ShouldBeFalse("choosing lowers the sheet rather than stacking on it");
|
|
vault.IsEditing.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheAddSheet_ClosesWithoutOpeningAnything()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.OpenAddSheetCommand.Execute(null);
|
|
vault.CloseAddSheetCommand.Execute(null);
|
|
|
|
vault.AnEditorIsOpen.ShouldBeFalse();
|
|
vault.IsEditing.ShouldBeFalse();
|
|
vault.IsEditingGroup.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ANewGroupFromTheSheet_StartsEmptyRatherThanOnTheLastOneEdited()
|
|
{
|
|
// The failure this rules out is quiet: a group editor left holding the previous group's default key
|
|
// would lend it to the next group somebody created without anybody choosing it.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await AddGroupAsync(vault, "production");
|
|
await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy", key: "deploy");
|
|
|
|
vault.OpenAddSheetCommand.Execute(null);
|
|
vault.NewGroupCommand.Execute(null);
|
|
|
|
vault.IsEditingGroup.ShouldBeTrue();
|
|
vault.IsAddSheetOpen.ShouldBeFalse();
|
|
vault.EditingGroupId.ShouldBeNull("this is an add, not a rename");
|
|
vault.GroupEditorLabel.ShouldBeEmpty();
|
|
vault.GroupEditorDefaultPort.ShouldBeNull();
|
|
vault.GroupEditorDefaultUsername.ShouldBeEmpty();
|
|
vault.GroupEditorSelectedAuthentication.ShouldBe(AuthenticationChoice.NoDefault);
|
|
vault.GroupEditorSelectedParent.ShouldBe(GroupChoice.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AGroupAddedFromTheSheet_CarriesTheDefaultsTypedIntoIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewGroupCommand.Execute(null);
|
|
vault.GroupEditorLabel = "production";
|
|
vault.GroupEditorDefaultPort = 2222;
|
|
vault.GroupEditorDefaultUsername = "deploy";
|
|
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
|
|
vault.IsEditingGroup.ShouldBeFalse("saving lowers the card");
|
|
|
|
var group = vault.Groups.Single();
|
|
group.Group.Label.ShouldBe("production");
|
|
group.Group.DefaultPort.ShouldBe(2222);
|
|
group.Group.DefaultUsername.ShouldBe("deploy");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheSheet_RefusesToOpenOverAnEditorRatherThanStackingOnIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.OpenAddSheetCommand.Execute(null);
|
|
|
|
vault.IsAddSheetOpen.ShouldBeFalse();
|
|
vault.Status.ShouldContain("Finish or cancel", Case.Insensitive);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ANewHostFromTheSheet_LeavesItsPortToWhicheverGroupItIsFiledUnder()
|
|
{
|
|
// The phone's add flow end to end, and the reason the port box opens empty: a host created under a
|
|
// group that says 2222 wants 2222 without anybody typing it, and stays wanting whatever the group
|
|
// says afterwards.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddGroupAsync(vault, "production");
|
|
await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy");
|
|
|
|
vault.OpenAddSheetCommand.Execute(null);
|
|
vault.NewHostCommand.Execute(null);
|
|
|
|
vault.EditorPort.ShouldBeNull("an empty box is what leaves the port to the group");
|
|
|
|
vault.EditorLabel = "prod-db";
|
|
vault.EditorHostname = "db.internal";
|
|
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
|
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
|
|
|
vault.EditorPortPlaceholder.ShouldBe("2222", "the form says what leaving it blank will get you");
|
|
vault.EditorUsernamePlaceholder.ShouldBe("deploy");
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
var host = Host(vault, "prod-db");
|
|
host.Host.Port.ShouldBeNull("nothing was typed, so nothing was pinned");
|
|
host.Resolved.Port.Value.ShouldBe(2222);
|
|
host.Resolved.Username.Value.ShouldBe("deploy");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AGroupsParentPicker_LetsAGroupBeFiledUnderAnother()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddGroupAsync(vault, "estate");
|
|
await AddGroupAsync(vault, "production");
|
|
await SetGroupParentAsync(vault, "production", "estate");
|
|
|
|
vault.Groups
|
|
.Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal))
|
|
.Group.ParentId
|
|
.ShouldBe(vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal))
|
|
.EntityId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHostUnderANestedGroup_TakesTheNearestAnswerAndKeepsWalkingForTheRest()
|
|
{
|
|
// Two levels, and each field resolved on its own: a group that answers one question does not stop
|
|
// the walk for the others.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "estate");
|
|
await AddGroupAsync(vault, "production");
|
|
await SetGroupDefaultsAsync(vault, "estate", username: "root");
|
|
await SetGroupDefaultsAsync(vault, "production", port: 2222);
|
|
await SetGroupParentAsync(vault, "production", "estate");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorUsername = string.Empty;
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
var host = Host(vault, "prod-db");
|
|
host.Resolved.Port.Value.ShouldBe(2222, "the nearer group answers the port");
|
|
host.Resolved.Username.Value.ShouldBe("root", "and the walk carries on for the one it did not");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ◆ <b>The gesture split, from the side that costs something to get wrong.</b> Choosing a host used to
|
|
/// raise a connect card — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list —
|
|
/// which meant a tap on a machine's name put five controls in the way of the one thing it obviously
|
|
/// means. A tap connects now, and this pins that it raises nothing on the way past.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ATapOnAHost_ConnectsAndRaisesNothing()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.SelectedHost = null;
|
|
|
|
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
|
|
|
|
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
|
vault.SelectedHost.ShouldNotBeNull("the row a tap landed on is what was connected to");
|
|
vault.IsAskingForConnectPassword.ShouldBeFalse("and nothing was raised over the list to do it");
|
|
vault.IsChoosingHosts.ShouldBeFalse("a tap is not a way into selection mode");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The one tap that cannot finish, and the whole of what is left of the connect card. A host that
|
|
/// authenticates with a typed password has nowhere to be given one from a list, so the tap raises the
|
|
/// password sheet and says so. What it must never do is connect with no password, or leave somebody
|
|
/// tapping a row that silently does nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ATapOnAHostThatWantsAPassword_RaisesTheSheetInsteadOfConnecting()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.SelectedHost = null;
|
|
|
|
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
|
|
|
|
vault.IsAskingForConnectPassword.ShouldBeTrue("there is nowhere else to type it");
|
|
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
|
|
vault.Status.ShouldContain("password");
|
|
ssh.Requests.ShouldBeEmpty("nothing was dialled with no password");
|
|
|
|
// The second tap, with the box filled in, is the one that goes through — otherwise the sheet would
|
|
// be answering the instruction it just gave with the same instruction again.
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ConnectPassword = "typed-in";
|
|
|
|
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
|
|
|
|
ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType<SshPasswordCredential>();
|
|
vault.IsAskingForConnectPassword.ShouldBeFalse("and the sheet goes with the tap that succeeded");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Dismissing takes the typed password with it, which is the same bargain the files screen's own picker
|
|
/// makes: a secret left in the box would be somebody else's password sitting in the field the next tap
|
|
/// reads — and, worse, it would satisfy the emptiness check that decides whether to raise the sheet at
|
|
/// all, so the next tap would dial with it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DismissingThePasswordSheet_EmptiesTheBox()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
|
|
|
|
vault.ConnectPassword = "half-typed";
|
|
vault.RemembersConnectPassword = true;
|
|
|
|
vault.CancelConnectPasswordCommand.Execute(null);
|
|
|
|
vault.IsAskingForConnectPassword.ShouldBeFalse();
|
|
vault.ConnectPassword.ShouldBeEmpty();
|
|
vault.RemembersConnectPassword.ShouldBeFalse("and the tick beside it is not carried either");
|
|
}
|
|
|
|
// ---- ◆ Choosing hosts, and the seven things the action bar does to them ----
|
|
//
|
|
// The connect card is gone and a long press chooses instead. What these pin is the shape of that: the
|
|
// set survives the things that used to empty it, the entries that are about one machine are offered only
|
|
// for one, and every run over the set says what it left alone.
|
|
|
|
/// <remarks>
|
|
/// The two gestures, from the side the phone drives them. A long press adds rather than toggling — a
|
|
/// second one on a machine somebody is holding down on must not take the tick off — and a tap toggles
|
|
/// once the mode is up. Emptying the set leaves the mode, which is the other way out of it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ALongPressChoosesAHost_AndTapsTickAndUntickFromThereOn()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "staging");
|
|
|
|
vault.IsChoosingHosts.ShouldBeFalse();
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
|
|
vault.IsChoosingHosts.ShouldBeTrue();
|
|
vault.ChosenHostCount.ShouldBe(1);
|
|
vault.HasOneChosenHost.ShouldBeTrue();
|
|
Host(vault, "prod-db").IsChosen.ShouldBeTrue("the tick is drawn on the row");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.ChosenHostCount.ShouldBe(1, "a second long press on the same row is not an untick");
|
|
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
|
|
|
|
vault.ChosenHostCount.ShouldBe(2);
|
|
vault.HasOneChosenHost.ShouldBeFalse("neither the pencil nor CONNECT is about two machines");
|
|
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-db"));
|
|
|
|
vault.IsChoosingHosts.ShouldBeFalse("unticking the last one leaves selection mode");
|
|
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
|
|
/// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
|
|
/// somebody choosing what to do with eleven machines.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheChosenHosts_SurviveTheListBeingRebuilt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "staging");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.ChosenHostCount.ShouldBe(1, vault.Status);
|
|
Host(vault, "prod-db").IsChosen.ShouldBeTrue("written back onto the row the reload made");
|
|
Host(vault, "staging").IsChosen.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The cross at the left of the bar, and everything it has to take with it: a picker asking which vault
|
|
/// to move nothing to is not a state worth having.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClearingTheChoice_TakesTheMenuAndItsPanelsWithIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.OpenHostActionSheetCommand.Execute(null);
|
|
vault.DeleteChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsHostActionSheetOpen.ShouldBeFalse("choosing an entry lowers the menu");
|
|
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
|
|
vault.AChosenHostPanelIsOpen.ShouldBeTrue();
|
|
vault.ShowsAddButton.ShouldBeFalse("and the + stands down under a question");
|
|
|
|
vault.ClearHostChoiceCommand.Execute(null);
|
|
|
|
vault.IsChoosingHosts.ShouldBeFalse();
|
|
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
|
|
vault.ShowsAddButton.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The pencil at the right of the bar. It edits the one ticked host and leaves selection mode, because
|
|
/// the editor is a page over the list and a bar counting hosts above a form about one of them would be
|
|
/// two answers to what the screen is about.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePencil_OpensTheEditorOnTheOneChosenHostAndLeavesSelectionMode()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "staging");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "staging"));
|
|
vault.EditChosenHostCommand.Execute(null);
|
|
|
|
vault.IsEditing.ShouldBeTrue();
|
|
vault.EditorLabel.ShouldBe("staging");
|
|
vault.IsChoosingHosts.ShouldBeFalse();
|
|
|
|
vault.CancelEditCommand.Execute(null);
|
|
|
|
// Two ticked, and the pencil has nothing to be about — the bar collapses it rather than refusing it,
|
|
// but the command has to agree or a stale binding would open the editor on a guess.
|
|
vault.ChooseHostCommand.Execute(Host(vault, "staging"));
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
|
|
vault.EditChosenHostCommand.Execute(null);
|
|
|
|
vault.IsEditing.ShouldBeFalse("there is no sensible reading of editing two machines");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Filing is the reason the set is worth having: thirty imported machines under one heading used to be
|
|
/// thirty rounds of open, pick, save. It is the same write dragging a card onto a group makes on the
|
|
/// desktop, run over the whole selection.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "prod-web");
|
|
await AddHostAsync(vault, "staging");
|
|
await AddGroupAsync(vault, "production");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
|
|
|
vault.RegroupChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
|
|
|
|
vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
|
|
.Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
|
|
|
await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
|
|
|
|
var group = vault.Groups.Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
|
|
|
Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
|
|
Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
|
|
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
|
|
|
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A question about six hosts does not survive the set becoming seven.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The question names a count and the run that answers it reads the set again, and the panel is drawn
|
|
/// above the list rather than over it — deliberately, so the ticked rows stay in view — which leaves
|
|
/// every one of them still tickable while it is up. One more tick between the question and the answer
|
|
/// used to delete a machine nobody had been asked about. The desktop is where this became easy: a
|
|
/// Ctrl-click or a band is a second's work. See <c>VaultViewModel.deletionAskedAbout</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TickingAnotherHost_DropsTheDeletionQuestionAlreadyOnScreen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "prod-web");
|
|
await AddHostAsync(vault, "staging");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
|
|
|
vault.DeleteChosenHostsCommand.Execute(null);
|
|
|
|
vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 2 hosts?");
|
|
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
|
|
|
|
vault.IsConfirmingChosenHostDeletion
|
|
.ShouldBeFalse("the question was about two of them and there are three now");
|
|
vault.IsChoosingHosts.ShouldBeTrue("the set is what was just chosen, so it stays");
|
|
|
|
vault.DeleteChosenHostsCommand.Execute(null);
|
|
|
|
vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 3 hosts?");
|
|
|
|
// And unticking back to the set it was asked about does not bring a stale question back up.
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
|
|
|
|
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ v5: the drag this used to cover — a ticked set dropped straight onto a group card — left with the
|
|
/// cards, and <c>FileChosenHostsUnderCommand</c> went with it; <c>ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce</c>
|
|
/// covers what a set files to now that the group picker is the only route. What survives here is the
|
|
/// guard: a drop was a gesture on the grid, refused under a half-typed edit because rewriting a host
|
|
/// underneath one was a save nobody asked for and could not then cancel. The picker inherits the same
|
|
/// refusal at the point it is raised instead — see <see cref="VaultViewModel.RegroupChosenHosts"/> —
|
|
/// rather than at the point it is answered, since a picker cannot be dropped onto something mid-edit the
|
|
/// way a card once was.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "prod-web");
|
|
await AddHostAsync(vault, "staging");
|
|
await AddGroupAsync(vault, "production");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
|
|
vault.RegroupChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsRegroupingChosenHosts.ShouldBeFalse("nothing is filed under an open editor");
|
|
|
|
vault.CancelEditCommand.Execute(null);
|
|
|
|
vault.RegroupChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsRegroupingChosenHosts.ShouldBeTrue("the editor is out of the way now");
|
|
|
|
var group = vault.Groups.Single(
|
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
|
|
|
vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
|
|
.Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
|
|
|
await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
|
|
|
|
Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
|
|
Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
|
|
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
|
|
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
|
|
/// seeing a machine filed under nothing. Checked when the picker is asked for and over the whole set —
|
|
/// see <see cref="VaultViewModel.RegroupChosenHosts"/> — rather than once per host mid-write, which is
|
|
/// why no panel opens at all and the status line's sentence has to carry the whole explanation.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var vaults = shell.Vaults;
|
|
|
|
await vaults.LoadAsync(Token);
|
|
|
|
vaults.NewVaultCommand.Execute(null);
|
|
vaults.NewVaultName = "Platform secrets";
|
|
|
|
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
|
|
|
var vault = shell.Vault!;
|
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
|
|
|
await vault.LoadAsync(Token);
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorSelectedVault =
|
|
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
|
vault.EditorLabel = "prod-web";
|
|
vault.EditorHostname = "web.internal";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
|
|
|
vault.RegroupChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsRegroupingChosenHosts.ShouldBeFalse("a group belongs to one keychain");
|
|
vault.Status.ShouldStartWith("These hosts are in more than one keychain");
|
|
vault.IsChoosingHosts.ShouldBeTrue("the set was refused, not dissolved");
|
|
|
|
// Unticking the visitor is all it takes: the refusal is about the set, not a latch the screen has
|
|
// to be talked out of.
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
|
|
|
vault.RegroupChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
|
|
/// another vault: the copy stays in the same keychain, so everything it points at is still there.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DuplicatingTheChosenHosts_WritesACopyBesideEachOne()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
|
|
await vault.DuplicateChosenHostsCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.Count.ShouldBe(2, vault.Status);
|
|
|
|
var copy = vault.Hosts.Single(row => row.Label.EndsWith("copy", StringComparison.Ordinal));
|
|
|
|
copy.Label.ShouldBe("prod-db copy");
|
|
copy.Host.Hostname.ShouldBe(Host(vault, "prod-db").Host.Hostname);
|
|
copy.Host.GroupId.ShouldNotBeNull("a duplicate stays on the shelf it was made from");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One question naming a count, rather than one question per host: six copies of "delete prod-db?" is
|
|
/// not a confirmation anybody reads. What is pinned as well is that the question is answerable — the
|
|
/// panel it is drawn in is above the list rather than in place of it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RemovingTheChosenHosts_AsksOnceAndThenTakesThemAll()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "prod-web");
|
|
await AddHostAsync(vault, "staging");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
|
|
|
vault.DeleteChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
|
|
vault.PendingDeletion!.Question.ShouldContain("2 hosts");
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem(vault.Status).Label.ShouldBe("staging");
|
|
vault.IsChoosingHosts.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// KEEP leaves everything alone, including the ticks: the question was about the selection and declining
|
|
/// it is not a reason to throw the selection away.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task KeepingTheChosenHosts_LeavesTheTicksWhereTheyWere()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.DeleteChosenHostsCommand.Execute(null);
|
|
vault.CancelDeleteCommand.Execute(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem();
|
|
vault.IsChoosingHosts.ShouldBeTrue();
|
|
vault.ChosenHostCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// With one writable keychain there is nowhere to send anything, and the honest answer is a sentence
|
|
/// rather than an empty picker. It is also what somebody in a team whose only other vault is read-only
|
|
/// sees. The two-vault path is <c>VaultSharingTests</c>' job, which is where a second vault exists.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task MovingTheChosenHostsWithNowhereToPutThem_SaysSoRatherThanOpeningAnEmptyPicker()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.MoveChosenHostsToVaultCommand.Execute(null);
|
|
|
|
vault.IsSendingChosenHostsToAVault.ShouldBeFalse();
|
|
vault.Status.ShouldContain("only keychain");
|
|
vault.IsChoosingHosts.ShouldBeTrue("and the selection is left alone to be used for something else");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The bar's own CONNECT, which is the entry a tap already is — it is in the menu because the bar is
|
|
/// what a long press leaves you in, and without it connecting to the machine you had just chosen would
|
|
/// mean leaving selection mode first.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingFromTheActionBar_OpensTheHostAndLeavesSelectionMode()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
|
|
|
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
|
vault.IsChoosingHosts.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ◆ <b>"Connect via SFTP", which crosses from the vault to the shell.</b> Which machine is a decrypted
|
|
/// item and so is the vault's; the screen it leads to and the transfers view model behind it are the
|
|
/// shell's. This pins the join — the host arrives chosen in the file screen's own list, which is a copy
|
|
/// rebuilt from the vault's, so handing it the vault's row object would select nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task BrowsingAChosenHost_GoesToTheFilesScreenWithThatHostChosen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
vault.BrowseChosenHostCommand.Execute(null);
|
|
|
|
shell.IsTransfersShowing.ShouldBeTrue();
|
|
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
|
vault.IsChoosingHosts.ShouldBeFalse();
|
|
|
|
// A host with no key and no credential wants a typed password, and there is nowhere on a list to
|
|
// give it one — so the picker opens with the machine already chosen and the box beside it, which is
|
|
// the same branch a tap on the hosts screen makes.
|
|
shell.Transfers.IsChoosingRemote.ShouldBeTrue();
|
|
shell.Transfers.Status.ShouldContain("password");
|
|
}
|
|
|
|
// ---- The v5b session sidebar's QUICK ACCESS ----
|
|
|
|
/// <summary>
|
|
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
|
|
/// the connection already open and the tab it opened already selected.
|
|
/// </summary>
|
|
private async Task<VaultViewModel> ConnectedHostWithAPinAsync(string path = "/var/www/app")
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorNewPin = path;
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
|
|
|
return vault;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheSidebar_ShowsTheConnectedTabsHostsPins()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
|
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
|
|
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheSidebar_StaysHiddenBeforeAnythingConnects()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
// Pinned, but nothing has dialled it yet, and no tab exists for the sidebar to be about — it is keyed
|
|
// to a selected tab, not to the host that happens to be selected on the hosts screen.
|
|
shell.ShowsQuickAccessSidebar.ShouldBeFalse();
|
|
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// v5b widened the sidebar's own gate from "this host has pins" to "a session is in focus" — the sidebar
|
|
/// draws QUICK ACCESS's own "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on
|
|
/// a host that has pinned nothing yet. So a connected tab with no pins now shows the sidebar with an empty
|
|
/// QUICK ACCESS list rather than hiding it, which is the opposite of what the old pin strip did.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheSidebar_ShowsWithAnEmptyQuickAccessForAHostWithNoPins()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await AddKeyAsync(vault, "deploy");
|
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
|
|
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue("a session is open, even though this host pins nothing");
|
|
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheSidebar_HidesWhenTheSurfaceLeavesTheTerminalOrSftp()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
|
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
|
|
shell.ShowsQuickAccessSidebar.ShouldBeFalse(
|
|
"a page is showing, not the terminal or SFTP the sidebar sits beside");
|
|
|
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
|
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue("back on the terminal surface, with the same tab selected");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other surface the sidebar draws on since v5b: SFTP, gated on <c>Transfers.IsConnected</c> rather
|
|
/// than on a selected tab, since a session on that screen is its own connection — see
|
|
/// <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheSidebar_ShowsOnTheSftpSurfaceOnceConnected()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
|
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
|
|
|
shell.IsTransfersShowing.ShouldBeTrue();
|
|
shell.ShowsQuickAccessSidebar.ShouldBeTrue("the SFTP surface has a connected host of its own now");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The sidebar's QUICK ACCESS click handler, exercised through the fake SFTP factory rather than mocked: the
|
|
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
|
|
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
|
|
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
|
|
/// entry independent of Requests.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClickingAPinChip_OpensFilesAtThatPath()
|
|
{
|
|
var vault = await ConnectedHostWithAPinAsync();
|
|
|
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
|
|
|
shell.IsTransfersShowing.ShouldBeTrue();
|
|
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
|
shell.Transfers.IsConnected.ShouldBeTrue();
|
|
shell.Transfers.RemotePath.ShouldBe("/var/www/app");
|
|
|
|
ssh.SftpRequests.ShouldHaveSingleItem();
|
|
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
|
|
}
|
|
|
|
// ---- The v5b session shell: tab rows, header/status-bar facts, cross-surface buttons ----
|
|
|
|
/// <remarks>
|
|
/// The SFTP tab row's click, resolved through the same "Browse files" plumbing a pin click already uses —
|
|
/// see the deviation recorded on <c>MainWindowViewModel.SelectFilesHostCommand</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SelectingATabsFilesOpensSftpAtThatHostAndMarksTheTabSelected()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
|
|
var tab = shell.Tabs[0];
|
|
shell.SelectedTab = null;
|
|
|
|
await shell.SelectFilesHostCommand.ExecuteAsync(tab);
|
|
|
|
shell.SelectedTab.ShouldBe(tab, "the tab row's own active mark reads IsSelected");
|
|
shell.IsTransfersShowing.ShouldBeTrue();
|
|
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
|
shell.Transfers.IsConnected.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The header's "Open terminal" button on the SFTP surface — the other half of the two cross-surface
|
|
/// directions the v5b notes ask for, through <c>VaultViewModel.ConnectCommand</c> rather than through a
|
|
/// tab that does not exist.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OpeningATerminalFromSftpConnectsANewTerminalToTheBrowsedHost()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
|
|
|
shell.Transfers.IsConnected.ShouldBeTrue();
|
|
var tabsBefore = shell.Tabs.Count;
|
|
|
|
await shell.OpenTerminalForFilesHostCommand.ExecuteAsync(null);
|
|
|
|
shell.Tabs.Count.ShouldBe(tabsBefore + 1, "a new terminal connected to the browsed host");
|
|
shell.SelectedTab.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
|
|
/// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
|
|
/// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
|
|
/// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
|
|
/// that same mechanism rather than reimplementing it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
|
|
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
|
|
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
|
|
|
|
var row = snippets.Visible.ShouldHaveSingleItem();
|
|
|
|
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
|
|
|
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddingASnipFromTheSidebar_OpensTheSnippetsScreenWithTheEditorOpen()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.AddSnippetFromSidebarCommand.Execute(null);
|
|
|
|
shell.IsSnippetsShowing.ShouldBeTrue();
|
|
shell.SnippetsScreen.ShouldNotBeNull().IsEditing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The closest honest affordance the v5b notes ask for: this application cannot open a host editor
|
|
/// scrolled to one card, so "+ Pin folder" opens the whole editor on the active tab's host, the same as
|
|
/// the hosts screen's own EDIT does.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task PinningAFolderFromTheSidebar_OpensTheActiveTabsHostEditor()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
shell.PinFolderFromSidebarCommand.Execute(null);
|
|
|
|
shell.IsHostsShowing.ShouldBeTrue();
|
|
vault.IsEditing.ShouldBeTrue();
|
|
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
|
|
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
|
|
/// opens, and this reads it back through the same clock. The cipher and host-key algorithm are the
|
|
/// fake's own, and the identity text is the bound key's label — <c>ConnectedHostWithAPinAsync</c> binds
|
|
/// "deploy" — composed with the real (unshortened) algorithm string.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
|
|
shell.IsSessionConnected.ShouldBeTrue();
|
|
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
|
|
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
|
|
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
|
|
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The honesty rule stated as a test: with nothing open, the status bar has no facts to show rather than
|
|
/// a blank or a placeholder standing in for them.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SessionFacts_AreAbsentWithNoSessionOpen()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.IsSessionConnected.ShouldBeFalse();
|
|
shell.SessionAddress.ShouldBeNull();
|
|
shell.SessionElapsedText.ShouldBeNull();
|
|
shell.SessionCipher.ShouldBeNull();
|
|
shell.SessionHostKeyAlgorithm.ShouldBeNull();
|
|
shell.SessionIdentityText.ShouldBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A typed password has nothing filed in the keychain to name, so the status bar's identity run is the
|
|
/// host-key algorithm alone — no " · " and nothing after it, which is what
|
|
/// <see cref="MainWindowViewModel.SessionIdentityText"/>'s own remark promises rather than a placeholder
|
|
/// standing in for the item that was never there.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
|
{
|
|
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);
|
|
|
|
shell.IsSessionConnected.ShouldBeTrue();
|
|
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519", "a typed password names no keychain item");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The same facts on the other surface, and one asymmetry worth pinning: the SFTP session is its own
|
|
/// login through the same resolution ladder, so the status bar names that session's cipher, host key and
|
|
/// identity — and stops naming them on disconnect, where a terminal tab keeps its facts for the
|
|
/// scrollback still on screen (see <c>TerminalTabViewModel.Cipher</c>'s remark).
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SessionFacts_FollowTheSftpSurfacesOwnSession()
|
|
{
|
|
await ConnectedHostWithAPinAsync();
|
|
|
|
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
|
|
|
|
shell.IsTransfersShowing.ShouldBeTrue();
|
|
shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
|
|
shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
|
|
|
|
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
|
|
|
shell.SessionCipher.ShouldBeNull("the SFTP surface has no scrollback for a dead session's facts to describe");
|
|
shell.SessionIdentityText.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
|
|
{
|
|
// Both heads' only route into a group editor since v5: neither draws a group card to select one
|
|
// from any more, so the command has to work off the heading it was pressed on. Two groups exist here
|
|
// so a bug reading the wrong one would show up as the wrong label rather than passing by accident.
|
|
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");
|
|
|
|
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 some other group");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The heading hands its group straight to the editor rather than selecting a card first, and this is
|
|
/// why: through v4 a group selection cleared the host selection, since the desktop's two grids shared one
|
|
/// mark, and neither head has drawn a group card to select since v5 — see <c>EditGroup</c>'s own remarks.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
var host = vault.Hosts.Single();
|
|
vault.SelectedHost = host;
|
|
|
|
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
|
|
|
vault.EditGroupFromHeadingCommand.Execute(heading);
|
|
|
|
vault.IsEditingGroup.ShouldBeTrue("the editor still opens on the group the heading names");
|
|
vault.GroupEditorLabel.ShouldBe("production");
|
|
vault.SelectedHost.ShouldBeSameAs(host, "and the list is still on the machine it was on");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheUngroupedHeading_OpensNothing()
|
|
{
|
|
// It has no group behind it. The button is hidden there, so this is the guard for the path the
|
|
// markup does not control.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
|
|
var ungrouped = vault.SidebarRows.OfType<SidebarGroupHeader>()
|
|
.FirstOrDefault(row => row.GroupId is null);
|
|
|
|
ungrouped.ShouldNotBeNull("a vault with a group and an unfiled host draws an ungrouped heading");
|
|
|
|
vault.EditGroupFromHeadingCommand.Execute(ungrouped);
|
|
|
|
vault.IsEditingGroup.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The load-bearing half of giving the phone a group menu, and the reason the commands take a header at
|
|
/// all. <c>DeleteGroup</c> and <c>MoveGroup</c> aim at <c>GroupTarget</c>, which is the selected card or
|
|
/// the open group — and the phone has neither, because its list draws headings and a heading is not a
|
|
/// thing that list can select. Called bare on that head they would return having done nothing, which is
|
|
/// a DELETE that appears to have been pressed and has not.
|
|
/// </para>
|
|
/// <para>
|
|
/// The tick is asserted <em>off</em> as well as present. Off is "the machines stay and turn up under
|
|
/// UNGROUPED", which is recoverable; on is not, and a question that arrived with the destructive answer
|
|
/// already given would be worse than one that never asked.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AGroupsHeading_AsksAboutThatGroupWithNothingSelected()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
|
|
|
vault.GroupTarget.ShouldBeNull("the phone selects no card and opens no group");
|
|
|
|
vault.OpenGroupSheetCommand.Execute(heading);
|
|
|
|
vault.GroupSheetLabel.ShouldBe("production", "the menu names what it is about");
|
|
vault.AnEditorIsOpen.ShouldBeTrue("so the + stands down, as it does under the add sheet");
|
|
|
|
vault.DeleteGroupFromHeadingCommand.Execute(heading);
|
|
|
|
vault.GroupSheet.ShouldBeNull("the menu closes behind the entry that was pressed");
|
|
vault.IsConfirmingGroupDeletion.ShouldBeTrue();
|
|
vault.PendingDeletion!.Question.ShouldContain("production");
|
|
vault.PendingDeletion.HasChoice.ShouldBeTrue("a host is filed under it, so it has a second question");
|
|
vault.DeletionTakesTheHostsToo.ShouldBeFalse("keeping them is the answer that needs no decision");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A menu is a thing you are allowed to decide against, which is why this one is dismissible where the
|
|
/// host key sheet deliberately is not. Nothing may be left armed behind it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheGroupsMenu_WavedAway_LeavesEverythingAsItWas()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
await FileAsync(vault, "prod-db", "production");
|
|
|
|
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
|
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
|
|
|
vault.OpenGroupSheetCommand.Execute(heading);
|
|
vault.CloseGroupSheetCommand.Execute(null);
|
|
|
|
vault.GroupSheet.ShouldBeNull();
|
|
vault.AnEditorIsOpen.ShouldBeFalse();
|
|
vault.IsConfirmingDeletion.ShouldBeFalse();
|
|
vault.IsMovingGroup.ShouldBeFalse();
|
|
vault.IsEditingGroup.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheUngroupedHeading_RaisesNoMenu()
|
|
{
|
|
// Nothing behind it for the three entries to act on. The button is left off that row, so this is the
|
|
// guard for the path the markup does not control.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
|
|
var ungrouped = vault.SidebarRows.OfType<SidebarGroupHeader>()
|
|
.Single(row => row.GroupId is null);
|
|
|
|
vault.OpenGroupSheetCommand.Execute(ungrouped);
|
|
|
|
vault.GroupSheet.ShouldBeNull();
|
|
}
|
|
|
|
// ---- Deleting a host, from the phone's bar ----
|
|
|
|
/// <remarks>
|
|
/// ◆ <b>The three panels the action bar's menu can raise, and the rule that at most one is up.</b> They
|
|
/// are drawn above the list rather than over it — the ticked rows are the information the question exists
|
|
/// to give — so each one has to disarm the other two on the way up, or two questions about the same six
|
|
/// machines would be stacked, one of them destructive.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddGroupAsync(vault, "production");
|
|
|
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
|
|
|
vault.DeleteChosenHostsCommand.Execute(null);
|
|
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
|
|
|
|
vault.RegroupChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
|
|
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse("the question was disarmed on the way up");
|
|
|
|
vault.DeleteChosenHostsCommand.Execute(null);
|
|
|
|
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
|
|
vault.IsRegroupingChosenHosts.ShouldBeFalse("and the picker folded away in return");
|
|
}
|
|
|
|
// ---- Tags ----
|
|
//
|
|
// The type has been storable since the domain landed and unreachable until now. What these pin is the
|
|
// two halves of making it reachable: a chip is a name resolved through the tag list, and a picker is a
|
|
// set the host editor edits like any other field.
|
|
|
|
[Fact]
|
|
public async Task ATagPutOnAHost_ShowsAsAChipAndSurvivesAReload()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddTagAsync(vault, "pci");
|
|
|
|
await TagAsync(vault, "prod-db", "pci");
|
|
|
|
Host(vault, "prod-db").TagLabels.ShouldBe(["pci"]);
|
|
Host(vault, "prod-db").HasTags.ShouldBeTrue();
|
|
Host(vault, "prod-db").Host.TagIds.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RenamingATag_ChangesEveryChipAndRewritesNoHost()
|
|
{
|
|
// The whole reason a tag is an item rather than a string on a host. If this ever needed to touch a
|
|
// host, the type would have earned nothing over repeating the name inside twenty payloads.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddHostAsync(vault, "stage-web");
|
|
await AddTagAsync(vault, "pci");
|
|
await TagAsync(vault, "prod-db", "pci");
|
|
await TagAsync(vault, "stage-web", "pci");
|
|
|
|
var before = Host(vault, "prod-db").Host;
|
|
|
|
vault.SelectedTag = vault.Tags.Single();
|
|
vault.EditTagCommand.Execute(null);
|
|
vault.TagEditorLabel = "pci-dss";
|
|
await vault.SaveTagCommand.ExecuteAsync(null);
|
|
|
|
Host(vault, "prod-db").TagLabels.ShouldBe(["pci-dss"]);
|
|
Host(vault, "stage-web").TagLabels.ShouldBe(["pci-dss"]);
|
|
|
|
Host(vault, "prod-db").Host.ShouldBe(before, "renaming a tag must not rewrite a host");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeletingATag_LeavesTheHostsAloneAndTheChipsGone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddTagAsync(vault, "pci");
|
|
await TagAsync(vault, "prod-db", "pci");
|
|
|
|
var wornBefore = Host(vault, "prod-db").Host.TagIds;
|
|
|
|
vault.SelectedTag = vault.Tags.Single();
|
|
vault.DeleteTagCommand.Execute(null);
|
|
|
|
vault.PendingDeletion.ShouldNotBeNull().Usage
|
|
.ShouldContain("1 host", Case.Insensitive, "the count is what makes this decidable");
|
|
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
vault.Tags.ShouldBeEmpty();
|
|
|
|
// The chip is gone and the id is not. Nothing rewrites N payloads inside one delete, so the host
|
|
// still names a tag that resolves to nothing — and would wear it again if the tag came back.
|
|
Host(vault, "prod-db").TagLabels.ShouldBeEmpty();
|
|
Host(vault, "prod-db").Host.TagIds.ShouldBe(wornBefore);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// v5c-2: the settings Tags page has no list selection to lean on the way the keychain screen's own
|
|
/// table does, so <c>EditTagRow</c>/<c>DeleteTagRow</c> select the row and then hand off to the real
|
|
/// commands above — this proves the hand-off reaches the same place, with the same guard sentences.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EditTagRow_SelectsTheRowThenOpensTheSameEditorEditTagDoes()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddTagAsync(vault, "pci");
|
|
var row = vault.Tags.Single();
|
|
|
|
vault.EditTagRowCommand.Execute(row);
|
|
|
|
vault.SelectedTag.ShouldBe(row);
|
|
vault.IsEditingTag.ShouldBeTrue();
|
|
vault.TagEditorLabel.ShouldBe("pci");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTagRow_SelectsTheRowThenArmsTheSameConfirmationDeleteTagDoes()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await AddTagAsync(vault, "pci");
|
|
await TagAsync(vault, "prod-db", "pci");
|
|
|
|
var row = vault.Tags.Single();
|
|
|
|
vault.DeleteTagRowCommand.Execute(row);
|
|
|
|
vault.SelectedTag.ShouldBe(row);
|
|
vault.PendingDeletion.ShouldNotBeNull().Usage
|
|
.ShouldContain("1 host", Case.Insensitive, "the same guard sentence DeleteTag would have armed");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
// ---- Pinned paths (QUICK ACCESS) ----
|
|
|
|
[Fact]
|
|
public async Task APinAddedThroughTheEditor_LandsOnTheHostOnSave()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorPinnedPaths.ShouldBeEmpty();
|
|
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
|
|
vault.EditorNewPin.ShouldBeEmpty("the box empties so a second one can be typed straight away");
|
|
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/var/www/app"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PinsAddedInOrder_KeepThatOrderOnTheHost()
|
|
{
|
|
// Order is the whole feature — see PinnedPathList's own remarks — so the editor's staging list has
|
|
// to preserve it as faithfully as the domain type it is about to become.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
foreach (var path in new[] { "/var/www/app", "/etc/nginx", "/var/log/pm2" })
|
|
{
|
|
vault.EditorNewPin = path;
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
}
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
Host(vault, "prod-db").Host.PinnedPaths
|
|
.ShouldBe(["/var/www/app", "/etc/nginx", "/var/log/pm2"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task APinRemovedInTheEditor_IsGoneFromTheHostOnSave()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
vault.EditorNewPin = "/etc/nginx";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
|
|
vault.RemoveEditorPinCommand.Execute("/var/www/app");
|
|
|
|
vault.EditorPinnedPaths.ShouldBe(["/etc/nginx"]);
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/etc/nginx"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CancellingAHostEdit_DropsThePinningEntirely()
|
|
{
|
|
// Unlike a tag, a pin has no id and nowhere else to live — so cancelling loses it outright rather
|
|
// than leaving it behind for next time, which is what CancellingAHostEdit_DropsTheTaggingAndKeepsThe
|
|
// Tag holds a tag's own name to.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
|
|
vault.CancelEditCommand.Execute(null);
|
|
|
|
Host(vault, "prod-db").Host.PinnedPaths.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReopeningAPinnedHostsEditor_StagesItsExistingPins()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ANewHostsEditor_OpensWithNoPinsStaged()
|
|
{
|
|
// NewHostCommand has to clear whatever the previous host's edit left in EditorPinnedPaths — the same
|
|
// reason every other editor field is reset there.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
|
|
vault.EditorPinnedPaths.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ABlankPin_IsRefusedAtTheAddBox()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorNewPin = " ";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
|
|
vault.EditorPinnedPaths.ShouldBeEmpty();
|
|
vault.Status.ShouldContain("blank");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task APinAlreadyStaged_IsRefusedRatherThanRepeated()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
|
|
vault.EditorPinnedPaths.ShouldHaveSingleItem();
|
|
vault.Status.ShouldContain("already pinned");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnOverLongPin_IsRefusedAtTheAddBoxBeforeSave()
|
|
{
|
|
// The add affordance has to catch this itself rather than letting it ride to TryValidate: a refusal
|
|
// that waits for SAVE throws away every other field typed on the form since, where one at the box
|
|
// that caused it costs nothing else.
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorNewPin = new string('a', HostSecret.MaxPinnedPathLength + 1);
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
|
|
vault.EditorPinnedPaths.ShouldBeEmpty();
|
|
vault.Status.ShouldContain(HostSecret.MaxPinnedPathLength.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AThirtyThirdPin_IsRefusedAtTheAddBox()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
vault.SelectedHost = Host(vault, "prod-db");
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
for (var i = 0; i < HostSecret.MaxPinnedPaths; i++)
|
|
{
|
|
vault.EditorNewPin = $"/pin/{i}";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
}
|
|
|
|
vault.EditorPinnedPaths.Count.ShouldBe(HostSecret.MaxPinnedPaths);
|
|
|
|
vault.EditorNewPin = "/one/too/many";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
|
|
vault.EditorPinnedPaths.Count.ShouldBe(
|
|
HostSecret.MaxPinnedPaths, "the add box refused the 33rd rather than staging it");
|
|
vault.Status.ShouldContain(HostSecret.MaxPinnedPaths.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
[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.EditGroupCommand.Execute(vault.Groups.Single(
|
|
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
|
|
|
|
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.EditGroupCommand.Execute(vault.Groups.Single(
|
|
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
|
|
|
|
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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ── v5b ── The desktop's own delete confirmation, additive beside the phone's uncounted DELETE — see
|
|
/// <c>SnippetsViewModel.RequestDelete</c>'s own remarks for why the two are separate commands. Pins the
|
|
/// two-step shape every other keychain item's deletion already has: arming changes nothing, and only
|
|
/// confirming actually removes it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RequestingDeleteOnASnippet_AsksFirstAndChangesNothingUntilConfirmed()
|
|
{
|
|
await UnlockedAsync();
|
|
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
|
|
|
|
await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: false);
|
|
|
|
snippets.Selected = snippets.Visible.Single();
|
|
snippets.RequestDeleteCommand.Execute(null);
|
|
|
|
snippets.IsConfirmingDelete.ShouldBeTrue();
|
|
snippets.DeleteQuestion.ShouldContain("restart the api");
|
|
snippets.ShowsSelectionActions.ShouldBeFalse("the confirm card takes the insert controls' place");
|
|
snippets.Visible.ShouldHaveSingleItem("arming the question deletes nothing by itself");
|
|
|
|
snippets.CancelDeleteCommand.Execute(null);
|
|
|
|
snippets.IsConfirmingDelete.ShouldBeFalse();
|
|
snippets.Visible.ShouldHaveSingleItem("cancelling leaves the snippet exactly where it was");
|
|
|
|
snippets.RequestDeleteCommand.Execute(null);
|
|
await snippets.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
|
|
snippets.IsConfirmingDelete.ShouldBeFalse();
|
|
snippets.Visible.ShouldBeEmpty();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ---- Logs ----
|
|
|
|
/// <remarks>
|
|
/// ── v5b ── Logs.dc.html's own two-segment CONNECTIONS/KEYCHAIN control, restyled onto the rail's own
|
|
/// track-and-segment idiom — see LogsScreen.axaml's own remark on why two segments and not three. Pins
|
|
/// that <c>LogSection</c> genuinely has only the two values the segments name, and that the two flags a
|
|
/// segment's own active state reads are mutually exclusive.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheLogsScreenSwitchesBetweenExactlyTheTwoRealSections()
|
|
{
|
|
await UnlockedAsync();
|
|
var logs = shell.LogsScreen.ShouldNotBeNull();
|
|
|
|
logs.Section.ShouldBe(LogSection.Connections, "the screen opens on connections");
|
|
logs.ShowsConnections.ShouldBeTrue();
|
|
logs.ShowsActivity.ShouldBeFalse();
|
|
|
|
logs.ShowSectionCommand.Execute(LogSection.Activity);
|
|
|
|
logs.ShowsActivity.ShouldBeTrue();
|
|
logs.ShowsConnections.ShouldBeFalse("the two flags are one fact read two ways and cannot both be true");
|
|
|
|
logs.ShowSectionCommand.Execute(LogSection.Connections);
|
|
logs.ShowsConnections.ShouldBeTrue();
|
|
|
|
Enum.GetValues<LogSection>().Length.ShouldBe(2, "the design draws two segments and the enum has two");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ── v5b ── The header's own status sentence — LogsViewModel.HeaderStatusLine — falls back to the per-
|
|
/// section fact this type's header comment already states truthfully, and steps aside for a refresh
|
|
/// error when refreshing just produced one.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHeaderStatusLineNamesTheSectionsOwnFactAndYieldsToARefreshError()
|
|
{
|
|
await UnlockedAsync();
|
|
var logs = shell.LogsScreen.ShouldNotBeNull();
|
|
|
|
logs.HeaderStatusLine.ShouldBe("an entry is written once, when a connection closes");
|
|
|
|
logs.ShowSectionCommand.Execute(LogSection.Activity);
|
|
logs.HeaderStatusLine.ShouldBe("one row per write, per device");
|
|
|
|
logs.Status = "network unreachable";
|
|
logs.HeaderStatusLine.ShouldBe("network unreachable", "a live refresh error outranks the section fact");
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <summary>Armoured material of a plausible shape, and deliberately not a usable key.</summary>
|
|
private static string PrivateKey(string body) =>
|
|
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
|
|
|
|
private Task<IVaultServer> SignInAsync(Uri serverUrl, CancellationToken cancellationToken)
|
|
{
|
|
// Counted so a test can assert that a rejected URL never got this far. Reaching here means a
|
|
// browser would have opened in the real application.
|
|
signInAttempts++;
|
|
|
|
return server.SignInFailure is { } failure
|
|
? Task.FromException<IVaultServer>(failure)
|
|
: Task.FromResult<IVaultServer>(server);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Counted and recorded, and never a browser: resuming is the path that must reach the token endpoint
|
|
/// and nothing else. <see cref="resumeFailure"/> stands in for a provider that refuses.
|
|
/// </remarks>
|
|
private Task<IVaultServer> ResumeAsync(
|
|
Uri serverUrl,
|
|
string refreshToken,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
resumeAttempts++;
|
|
resumedWith = refreshToken;
|
|
|
|
return resumeFailure is { } failure
|
|
? Task.FromException<IVaultServer>(failure)
|
|
: Task.FromResult<IVaultServer>(server);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A second shell over the same profile directory, as a relaunch of the application is.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Its sign-in delegate throws by default, which is the assertion rather than a convenience: a launch
|
|
/// that reached it would be one that opened a browser at somebody, and every test using this is about
|
|
/// a launch that must not.
|
|
/// </remarks>
|
|
private MainWindowViewModel Relaunch(
|
|
IDeviceKeyStore? keys = null,
|
|
MainWindowViewModel.ResumeHandler? resume = null) =>
|
|
new(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
keys ?? new UnavailableDeviceKeyStore(),
|
|
(_, _) => throw new InvalidOperationException("The shell opened a browser on launch."),
|
|
TimeProvider.System,
|
|
ssh,
|
|
CheapProfile,
|
|
resume);
|
|
|
|
private async Task SignedInAsync()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
shell.State.ShouldBe(ShellState.NeedsEnrollment);
|
|
}
|
|
|
|
private async Task EnrolledAsync()
|
|
{
|
|
await SignedInAsync();
|
|
|
|
shell.Passphrase = Passphrase;
|
|
shell.ConfirmPassphrase = Passphrase;
|
|
|
|
await shell.EnrollCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
private async Task EnrolledAndConfirmedAsync()
|
|
{
|
|
await EnrolledAsync();
|
|
|
|
shell.RecoveryCodeWrittenDown = true;
|
|
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
}
|
|
|
|
private Task ReadyToUnlockAsync() => EnrolledAndConfirmedAsync();
|
|
|
|
// ---- Unlocking with this machine's device key ----
|
|
|
|
[Fact]
|
|
public async Task AnUnlockedVaultOffersToRegisterThisMachine()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.CanRegisterDevice.ShouldBeTrue();
|
|
|
|
// Not before: a locked machine with nothing registered has nothing to offer, and the unlock screen
|
|
// must not show a gesture button for a key it does not have.
|
|
shell.CanUnlockWithDevice.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The phone's case, tested here because the seam is the shell's. <c>Environment.MachineName</c> answers
|
|
/// <c>localhost</c> on Android, so a head with no way to say what it is called would put one
|
|
/// indistinguishable row per phone into the account's device list — which is the list somebody revokes a
|
|
/// lost handset from, and a row nobody can identify is a revocation nobody dares press. See
|
|
/// docs/android-port.md §7.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AHeadThatKnowsWhatThisDeviceIsCalled_RegistersItUnderThatName()
|
|
{
|
|
// An enrolled account with a keychain on it, exactly as a phone signing in to an existing account
|
|
// finds. The registering shell below is a second one over the same profile, which is what every
|
|
// other "another launch" test in this file does.
|
|
await UnlockedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
var phone = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
deviceKeys,
|
|
SignInAsync,
|
|
TimeProvider.System,
|
|
ssh,
|
|
CheapProfile,
|
|
ResumeAsync,
|
|
deviceName: "Jaap's Pixel");
|
|
|
|
await using var _ = phone.ConfigureAwait(false);
|
|
|
|
await phone.StartAsync(Token);
|
|
await phone.SignInCommand.ExecuteAsync(null);
|
|
|
|
phone.Passphrase = Passphrase;
|
|
await phone.UnlockCommand.ExecuteAsync(null);
|
|
phone.State.ShouldBe(ShellState.Unlocked, phone.StatusMessage);
|
|
|
|
await phone.RegisterDeviceCommand.ExecuteAsync(null);
|
|
|
|
server.RegisteredDeviceNames.ShouldBe(["Jaap's Pixel"]);
|
|
|
|
// And it is said back, because "this phone can now unlock without your passphrase" is a sentence
|
|
// about one device out of several.
|
|
phone.StatusMessage.ShouldContain("Jaap's Pixel");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AHeadThatSaysNothing_RegistersUnderThisMachinesOwnName()
|
|
{
|
|
// The desktop, and the reason the parameter is optional: a head running on an operating system whose
|
|
// machine name is real passes nothing and gets it.
|
|
await UnlockedAsync();
|
|
await shell.RegisterDeviceCommand.ExecuteAsync(null);
|
|
|
|
server.RegisteredDeviceNames.ShouldBe([Environment.MachineName]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase()
|
|
{
|
|
// The shell's half of the feature, end to end through the commands a user actually presses.
|
|
await UnlockedAsync();
|
|
await shell.RegisterDeviceCommand.ExecuteAsync(null);
|
|
|
|
shell.CanRegisterDevice.ShouldBeFalse("it is registered now, so the offer is spent");
|
|
server.RegisteredDevices.Count.ShouldBe(1);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
await shell.StartAsync(Token);
|
|
|
|
shell.CanUnlockWithDevice.ShouldBeTrue("the wrap is cached and the keystore still has the key");
|
|
|
|
await shell.UnlockWithDeviceCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
shell.Passphrase.ShouldBeEmpty("nothing was typed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WithdrawingTheDevice_SendsThisMachineBackToThePassphraseAndClearsTheAccount()
|
|
{
|
|
// The button that makes revocation reachable at all. Until it existed, ForgetDeviceAsync had one
|
|
// caller and that caller was a test.
|
|
await UnlockedAsync();
|
|
await shell.RegisterDeviceCommand.ExecuteAsync(null);
|
|
|
|
shell.CanForgetDevice.ShouldBeTrue("there is a device key here now");
|
|
|
|
await shell.ForgetDeviceCommand.ExecuteAsync(null);
|
|
|
|
shell.CanForgetDevice.ShouldBeFalse("the offer is spent");
|
|
server.RegisteredDevices.ShouldBeEmpty("the account must not go on listing it");
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
await shell.StartAsync(Token);
|
|
|
|
shell.CanUnlockWithDevice.ShouldBeFalse("there is nothing left to unlock with");
|
|
|
|
// And the passphrase still opens it, which is what makes withdrawing safe to offer without a
|
|
// confirmation prompt.
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The offer and the withdrawal are two flags rather than one and its negation, and this is why: on a
|
|
/// machine with no keystore both are false, and a single flag would have made "cannot register" mean
|
|
/// "has something to withdraw".
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OnAMachineWithNoKeystore_ThereIsNothingToWithdrawEither()
|
|
{
|
|
deviceKeys.IsAvailable = false;
|
|
|
|
await UnlockedAsync();
|
|
|
|
shell.CanRegisterDevice.ShouldBeFalse();
|
|
shell.CanForgetDevice.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WithdrawingTheDeviceOffline_StopsThisMachineAndSaysTheAccountStillListsIt()
|
|
{
|
|
// Somebody who has just realised a machine is in the wrong hands may well be on a train. Refusing
|
|
// until they are online would leave the device unlocking itself for the whole journey.
|
|
await UnlockedAsync();
|
|
await shell.RegisterDeviceCommand.ExecuteAsync(null);
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
// The same keystore, so this machine still holds its device key — only the network is gone.
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
deviceKeys,
|
|
(_, _) => throw new InvalidOperationException("The shell went to the network."),
|
|
TimeProvider.System,
|
|
ssh,
|
|
CheapProfile);
|
|
|
|
await using var _ = offline.ConfigureAwait(false);
|
|
|
|
await offline.StartAsync(Token);
|
|
offline.IsOnline.ShouldBeFalse();
|
|
|
|
offline.Passphrase = Passphrase;
|
|
await offline.UnlockCommand.ExecuteAsync(null);
|
|
offline.State.ShouldBe(ShellState.Unlocked, offline.StatusMessage);
|
|
offline.CanForgetDevice.ShouldBeTrue();
|
|
|
|
await offline.ForgetDeviceCommand.ExecuteAsync(null);
|
|
|
|
offline.StatusMessage.ShouldContain("still lists it");
|
|
offline.CanForgetDevice.ShouldBeFalse();
|
|
server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend");
|
|
|
|
// The half that decides whether this machine may let itself in happened anyway.
|
|
await offline.LockCommand.ExecuteAsync(null);
|
|
await offline.StartAsync(Token);
|
|
|
|
offline.CanUnlockWithDevice.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnAMachineWithNoKeystore_NeitherAffordanceAppears()
|
|
{
|
|
deviceKeys.IsAvailable = false;
|
|
|
|
await UnlockedAsync();
|
|
|
|
shell.CanRegisterDevice.ShouldBeFalse();
|
|
shell.CanUnlockWithDevice.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ADeclinedGesture_LeavesTheUnlockScreenUsable()
|
|
{
|
|
// The fallback that makes the whole thing safe to offer: a cancelled prompt changes the message and
|
|
// nothing else, and the passphrase still opens the vault.
|
|
await UnlockedAsync();
|
|
await shell.RegisterDeviceCommand.ExecuteAsync(null);
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
await shell.StartAsync(Token);
|
|
|
|
deviceKeys.Decline = true;
|
|
await shell.UnlockWithDeviceCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
}
|
|
|
|
// ---- Staying signed in, and syncing on its own ----
|
|
|
|
/// <remarks>
|
|
/// The behaviour the whole remembered-sign-in mechanism exists for. Before it, a machine that had been
|
|
/// set up launched <em>offline</em> and stayed there until somebody found the SIGN IN button on the
|
|
/// preferences screen — so the sync loop ran once a minute against nothing, and a colleague's change
|
|
/// arrived when a user happened to go looking for it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARelaunchComesBackOnlineWithoutOpeningABrowser()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
// The pass that remembers the sign-in. It is the one the loop runs when the vault opens; driven
|
|
// here rather than raced against.
|
|
await shell.Vault!.SyncOnOpenAsync(Token);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
var relaunch = Relaunch(resume: ResumeAsync);
|
|
await using var _ = relaunch.ConfigureAwait(false);
|
|
|
|
await relaunch.StartAsync(Token);
|
|
|
|
relaunch.State.ShouldBe(ShellState.Locked);
|
|
relaunch.IsOnline.ShouldBeFalse(
|
|
"the token is sealed under the vault's key, so a locked machine cannot reach the server");
|
|
resumeAttempts.ShouldBe(0);
|
|
|
|
relaunch.Passphrase = Passphrase;
|
|
await relaunch.UnlockCommand.ExecuteAsync(null);
|
|
|
|
await relaunch.Vault!.SyncOnOpenAsync(Token);
|
|
|
|
relaunch.IsOnline.ShouldBeTrue();
|
|
resumedWith.ShouldBe("refresh-token-1");
|
|
signInAttempts.ShouldBe(1, "the browser opened once, at setup, and must not open again");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Providers rotate refresh tokens on use, and a client that persisted only the first one it saw would
|
|
/// present a retired token on the next launch and be signed out for no visible reason. This is the one
|
|
/// failure in the mechanism that would look like flakiness rather than a bug.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARotatedTokenIsTheOneTheNextLaunchPresents()
|
|
{
|
|
await UnlockedAsync();
|
|
await shell.Vault!.SyncOnOpenAsync(Token);
|
|
|
|
server.RefreshToken = "refresh-token-2";
|
|
await shell.Vault.SyncOnOpenAsync(Token);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
var relaunch = Relaunch(resume: ResumeAsync);
|
|
await using var _ = relaunch.ConfigureAwait(false);
|
|
|
|
await relaunch.StartAsync(Token);
|
|
relaunch.Passphrase = Passphrase;
|
|
await relaunch.UnlockCommand.ExecuteAsync(null);
|
|
|
|
await relaunch.Vault!.SyncOnOpenAsync(Token);
|
|
|
|
resumedWith.ShouldBe("refresh-token-2");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARefusedSignIn_IsSaidOnceAndNotRetriedForever()
|
|
{
|
|
await UnlockedAsync();
|
|
await shell.Vault!.SyncOnOpenAsync(Token);
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
// What a revoked session, or a rotation this machine missed, looks like from the token endpoint.
|
|
resumeFailure = new OidcException(
|
|
"The token endpoint returned 400: Invalid refresh token.", "invalid_grant");
|
|
|
|
var relaunch = Relaunch(resume: ResumeAsync);
|
|
await using var _ = relaunch.ConfigureAwait(false);
|
|
|
|
await relaunch.StartAsync(Token);
|
|
relaunch.Passphrase = Passphrase;
|
|
await relaunch.UnlockCommand.ExecuteAsync(null);
|
|
|
|
// The vault opens regardless: nothing about being signed out stops a passphrase working.
|
|
relaunch.State.ShouldBe(ShellState.Unlocked, relaunch.StatusMessage);
|
|
|
|
await relaunch.Vault!.SyncOnOpenAsync(Token);
|
|
|
|
relaunch.IsOnline.ShouldBeFalse();
|
|
relaunch.Vault.Status.ShouldContain("expired", Case.Insensitive);
|
|
|
|
var attempted = resumeAttempts;
|
|
attempted.ShouldBeGreaterThan(0);
|
|
|
|
// And the token is dropped rather than retried once a minute for the life of the profile.
|
|
await relaunch.Vault.SyncOnOpenAsync(Token);
|
|
|
|
resumeAttempts.ShouldBe(attempted);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the
|
|
/// loop is started from inside the unlock command, so the busy flag it yields to was raised by the
|
|
/// unlock itself. It cost a full minute of a machine that was online and out of date, and no test saw
|
|
/// it because every test called the pass by hand with nothing busy.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePassOnOpen_RunsEvenThoughUnlockingIsStillBusy()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
|
await AddHostAsync(vault, "prod-db");
|
|
server.SyncFailure = null;
|
|
|
|
vault.PendingChanges.ShouldBe(1, "there must be something to push for this to mean anything");
|
|
|
|
// Standing in for the unlock command that is still running when the loop starts its first pass.
|
|
vault.IsBusy = true;
|
|
|
|
await vault.SyncOnOpenAsync(Token);
|
|
|
|
vault.PendingChanges.ShouldBe(0, "the pass on open does not yield to the unlock that started it");
|
|
server.LiveRowCount.ShouldBe(1);
|
|
|
|
vault.IsBusy = false;
|
|
}
|
|
|
|
// ---- Signing out ----
|
|
|
|
[Fact]
|
|
public async Task SigningOutIsAQuestionFirst()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.SignOutCommand.Execute(null);
|
|
|
|
shell.IsConfirmingSignOut.ShouldBeTrue();
|
|
shell.IsAskingForThePassphrase.ShouldBeFalse("the two cards swap rather than stack");
|
|
shell.State.ShouldBe(ShellState.Unlocked, "arming the question changes nothing else");
|
|
shell.Vault.ShouldNotBeNull();
|
|
|
|
shell.CancelSignOutCommand.Execute(null);
|
|
|
|
shell.IsConfirmingSignOut.ShouldBeFalse();
|
|
shell.State.ShouldBe(ShellState.Unlocked);
|
|
shell.Vault.ShouldNotBeNull("cancelling must not have closed anything");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SigningOut_DeletesThisMachinesCopyAndLeavesTheVaultOnTheServer()
|
|
{
|
|
await UnlockedAsync();
|
|
await AddHostAsync(shell.Vault!, "prod-db");
|
|
|
|
server.LiveRowCount.ShouldBe(1);
|
|
|
|
shell.SignOutCommand.Execute(null);
|
|
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.Vault.ShouldBeNull("the vault's keys are gone");
|
|
shell.IsOnline.ShouldBeFalse("and so is the connection");
|
|
shell.AccountName.ShouldBeNull();
|
|
shell.IsConfirmingSignOut.ShouldBeFalse();
|
|
|
|
server.LiveRowCount.ShouldBe(1, "the vault lives on the server and signing out does not touch it");
|
|
|
|
// A relaunch finds a machine that has never been set up, which is what "reset" has to mean.
|
|
var relaunch = Relaunch();
|
|
await using var _ = relaunch.ConfigureAwait(false);
|
|
|
|
await relaunch.StartAsync(Token);
|
|
|
|
relaunch.State.ShouldBe(ShellState.NeedsServer);
|
|
relaunch.AccountName.ShouldBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The half that makes signing out a reset rather than a wipe: the cache is emptied and immediately
|
|
/// usable, so setting the machine up again needs no restart. It is also the way back for somebody who
|
|
/// has forgotten their passphrase, which is why the button is on the unlock screen too.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AfterSigningOut_TheSameApplicationCanBeSetUpAgain()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.SignOutCommand.Execute(null);
|
|
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
|
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
// The account is already enrolled — this machine forgot it, the server did not — so the wrap and
|
|
// the salt are cached again from /me and the old passphrase still opens them.
|
|
shell.State.ShouldBe(ShellState.Locked, shell.StatusMessage);
|
|
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
shell.Vault.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SigningOutWithQueuedChanges_SaysHowManyWillBeLost()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
// A change this machine made and could not send is the one thing signing out destroys that
|
|
// nothing else has a copy of, so the count is the whole point of the confirmation.
|
|
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.PendingChanges.ShouldBe(1);
|
|
|
|
shell.SignOutCommand.Execute(null);
|
|
|
|
shell.SignOutWarning.ShouldContain("1 change");
|
|
shell.SignOutWarning.ShouldContain("lost");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SigningOutWhileLocked_AdmitsItCannotCountWhatWouldBeLost()
|
|
{
|
|
await UnlockedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.SignOutCommand.Execute(null);
|
|
|
|
// The outbox is sealed under the key the vault holds, so a locked machine genuinely cannot count
|
|
// it. Saying "nothing will be lost" here would be a claim this state cannot support.
|
|
shell.SignOutWarning.ShouldContain("cannot be counted");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SigningOut_WithdrawsThisMachineFromTheAccount()
|
|
{
|
|
// The leftover ADR 0007 is about: a device wrap on the account whose private half has just been
|
|
// deleted is one nobody can account for and nothing can use.
|
|
await UnlockedAsync();
|
|
await shell.RegisterDeviceCommand.ExecuteAsync(null);
|
|
|
|
server.RegisteredDevices.Count.ShouldBe(1);
|
|
|
|
shell.SignOutCommand.Execute(null);
|
|
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
|
|
|
server.RegisteredDevices.ShouldBeEmpty();
|
|
deviceKeys.Peek().ShouldBeNull("this machine's own copy of the key goes too");
|
|
|
|
var relaunch = Relaunch(keys: deviceKeys);
|
|
await using var _ = relaunch.ConfigureAwait(false);
|
|
|
|
await relaunch.StartAsync(Token);
|
|
|
|
relaunch.CanUnlockWithDevice.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Signing out is the strongest thing this application does to itself, and it deliberately does not do
|
|
/// the one thing locking refuses to do either. The argument is the same one <c>LockAsync</c> carries:
|
|
/// a session that authenticated before is still running somebody's job, and a button that destroyed it
|
|
/// would be a button people stop pressing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SigningOut_LeavesOpenShellsRunningAndSaysSo()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
await workspace.OpenSessionAsync(
|
|
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
|
TerminalSize.Default,
|
|
Token);
|
|
|
|
shell.SignOutCommand.Execute(null);
|
|
|
|
shell.HasLiveSessions.ShouldBeTrue();
|
|
shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");
|
|
|
|
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
|
|
|
workspace.LiveSessionCount.ShouldBe(1);
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
}
|
|
|
|
// ---- File transfer ----
|
|
|
|
/// <remarks>
|
|
/// The list is followed rather than copied at unlock, which is the whole of this test. A snapshot taken
|
|
/// when the vault opened meant a host created five seconds later could not be picked here until the
|
|
/// keychain had been locked and opened again — and nothing on the screen explained why the machine that
|
|
/// was plainly in the host list was missing from the picker.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheTransfersScreen_FollowsTheVaultsHostList()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.Transfers.Hosts.ShouldBeEmpty("the vault has no hosts yet");
|
|
|
|
await AddHostAsync(shell.Vault!, "prod-db");
|
|
|
|
shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]);
|
|
shell.Transfers.SelectedHost?.Label.ShouldBe("prod-db", "the only host is the one to offer");
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.Hosts.ShouldBeEmpty("those rows carry decrypted secrets");
|
|
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The lock policy, applied to the other thing that can be in flight. <c>LockAsync</c> argues that
|
|
/// locking must not destroy work — it is what somebody does when they walk away from the machine, which
|
|
/// is exactly when a long transfer is most likely to be running. A screen rebuilt per unlock would have
|
|
/// dropped the session and with it whatever was moving.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Locking_LeavesAFileTransferConnectionOpenAndOnlyTakesTheHostListAway()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var vault = shell.Vault!;
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
shell.Transfers.Attach(vault, knownHosts);
|
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
|
|
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.IsConnected.ShouldBeTrue("locking the vault is not a disconnect");
|
|
|
|
// What it does take is the host list, and it has to: those rows carry decrypted secrets and the
|
|
// vault they came from has just been disposed.
|
|
shell.Transfers.Hosts.ShouldBeEmpty();
|
|
shell.Transfers.SelectedHost.ShouldBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The consequence of SSH.NET having no way to open an SFTP subsystem on an existing transport, made
|
|
/// visible: browsing a host's files authenticates again rather than reusing the terminal's connection.
|
|
/// It is asserted rather than merely written down because the host's audit log shows a second login,
|
|
/// and somebody will eventually be asked to explain it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingTheTransfersScreen_OpensItsOwnConnectionRatherThanReusingATerminals()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await ConnectWithRendererAsync(vault);
|
|
|
|
ssh.Requests.Count.ShouldBe(1);
|
|
ssh.SftpRequests.ShouldBeEmpty("opening a terminal must not open a file-transfer session");
|
|
|
|
shell.Transfers.Attach(vault, knownHosts);
|
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
|
|
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.SftpRequests.Count.ShouldBe(1);
|
|
ssh.Requests.Count.ShouldBe(1, "and it must not open a shell either");
|
|
|
|
// It opens on the account's home directory, which is the only path the layer knows without asking.
|
|
shell.Transfers.RemotePath.ShouldBe("/home/deploy");
|
|
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The phone's Files-screen chip row, proven at the view model rather than through Avalonia: a pin
|
|
/// saved on the host before this screen ever connects to it is read straight off the row's own
|
|
/// <c>HostSecret.PinnedPaths</c> at the moment <c>MarkHostConnected</c> runs, which is what
|
|
/// <see cref="TransfersViewModel.ConnectedPinnedPaths"/>'s own remark promises rather than a live follow
|
|
/// of the vault.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
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);
|
|
shell.Transfers.ConnectedPinnedPaths.ShouldBe(["/var/www/app"]);
|
|
shell.Transfers.HasConnectedPins.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half of <see cref="ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths"/>: the
|
|
/// chip row has to go with the connection it belongs to, or a later connect to a host with no pins would
|
|
/// show the previous host's.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DisconnectingTheTransfersScreen_ClearsConnectedPinnedPaths()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
vault.EditorNewPin = "/var/www/app";
|
|
vault.AddEditorPinCommand.Execute(null);
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.Attach(vault, knownHosts);
|
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
|
|
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
|
shell.Transfers.HasConnectedPins.ShouldBeTrue();
|
|
|
|
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
|
|
shell.Transfers.HasConnectedPins.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The phone's foreground-service question, proven at the view model rather than through Android: a
|
|
/// connect that opens an SFTP session is exactly the transition <c>SessionKeepAlive</c> needs to hear
|
|
/// about even when no transfer ever moves — see <see cref="TransfersViewModel.ActivityChanged"/>'s own
|
|
/// remark for why the queue's own raise, in <c>OnTransferChanged</c>, cannot cover a connect that never
|
|
/// touches <c>Transfers</c> at all.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingATransfersHost_RaisesActivityChangedAndTurnsOnHasLiveFileSession()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
shell.Transfers.Attach(vault, knownHosts);
|
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
|
|
|
var raised = 0;
|
|
shell.Transfers.ActivityChanged += (_, _) => raised++;
|
|
|
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
|
shell.Transfers.HasLiveFileSession.ShouldBeTrue();
|
|
raised.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half: a disconnect is as much a transition the service must hear about as a connect is,
|
|
/// because it is the moment the connection <see cref="TransfersViewModel.HasLiveFileSession"/> promised
|
|
/// was open stops being true — and the foreground service would otherwise keep the process alive over a
|
|
/// session that has already closed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DisconnectingTheTransfersScreen_RaisesActivityChangedAndTurnsOffHasLiveFileSession()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
shell.Transfers.Attach(vault, knownHosts);
|
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
|
|
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
|
shell.Transfers.HasLiveFileSession.ShouldBeTrue();
|
|
|
|
var raised = 0;
|
|
shell.Transfers.ActivityChanged += (_, _) => raised++;
|
|
|
|
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.HasLiveFileSession.ShouldBeFalse();
|
|
raised.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A bucket is an <c>IRemoteFileStore</c> with no <c>HostSecret</c> underneath it, so there is no
|
|
/// <c>PinnedPaths</c> to read at all — see <see cref="TransfersViewModel.OpenBucketAsync"/>'s own remark.
|
|
/// The bucket here is created through the same keychain route
|
|
/// <see cref="TheS3ScreenWithNoBuckets_SaysWhereOneIsMadeAndGoesThere"/> exercises, and
|
|
/// <see cref="FakeObjectStoreFactory"/> stands in for the network the way <see cref="FakeSshConnectionFactory"/>
|
|
/// already does for SFTP.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingABucket_LeavesConnectedPinnedPathsEmpty()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
|
|
|
|
vault.NewObjectStoreCommand.Execute(null);
|
|
vault.BucketEditorLabel = "Backups";
|
|
vault.BucketEditorBucket = "backups";
|
|
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
|
|
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
|
|
vault.BucketEditorRegion = "eu-west-1";
|
|
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
|
|
|
|
// Remote is what ConnectAsync branches on, and Attach's RefreshHosts has already auto-selected the
|
|
// host ReadyToConnectAsync left in the picker — without this line the command below dialled that
|
|
// host, and every assertion here passed only because that host happens to have no pins either. The
|
|
// ConnectedTo check is the proof the bucket path was actually taken.
|
|
shell.Transfers.Remote = RemoteKind.Bucket;
|
|
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
|
|
|
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.ConnectedTo.ShouldBe("s3://backups");
|
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
|
shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
|
|
shell.Transfers.HasConnectedPins.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A bucket is HTTP, per-request, with nothing open that a dying process would lose — see
|
|
/// <see cref="TransfersViewModel.HasLiveFileSession"/>'s own remark. <c>IsConnected</c> alone would have
|
|
/// answered this wrongly, which is exactly why the flag reads <c>ConnectedCipher</c> as well: nothing
|
|
/// underneath a bucket ever sets it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ConnectingABucket_LeavesHasLiveFileSessionOff()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
|
|
|
|
vault.NewObjectStoreCommand.Execute(null);
|
|
vault.BucketEditorLabel = "Backups";
|
|
vault.BucketEditorBucket = "backups";
|
|
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
|
|
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
|
|
vault.BucketEditorRegion = "eu-west-1";
|
|
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
|
|
|
|
// ReadyToConnectAsync already left a host in the picker, and Attach's own RefreshHosts auto-selects
|
|
// it — so without this the CONNECT command below would dial that host rather than open the bucket,
|
|
// and a host with no pins would make ConnectedPinnedPathsEmpty-style assertions pass for the wrong
|
|
// reason. Remote is what ConnectAsync actually branches on.
|
|
shell.Transfers.Remote = RemoteKind.Bucket;
|
|
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
|
|
|
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.ConnectedTo.ShouldBe("s3://backups", "proof this opened the bucket rather than the host");
|
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
|
shell.Transfers.HasLiveFileSession.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>A bucket that opens and lists as empty, so a bucket connect can be proven with no network.</summary>
|
|
private sealed class FakeObjectStoreFactory : IObjectStoreFactory
|
|
{
|
|
public IRemoteFileStore Open(ObjectStoreSecret store) => new FakeBucketStore();
|
|
}
|
|
|
|
/// <summary>The minimum <see cref="IRemoteFileStore"/> a bucket connect touches: home, then a listing.</summary>
|
|
private sealed class FakeBucketStore : IRemoteFileStore
|
|
{
|
|
public bool IsConnected => true;
|
|
|
|
public string HomeDirectory => "/";
|
|
|
|
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
|
Task.FromResult<IReadOnlyList<SftpEntry>>([]);
|
|
|
|
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
|
Task.FromResult<SftpEntry?>(null);
|
|
|
|
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
|
|
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
|
|
|
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
|
|
|
public Task DeleteAsync(string path, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
|
|
|
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The picker that replaced the desktop's connect bar, and the four things that put it away again. It
|
|
/// is a flag rather than a screen, so what is worth asserting is that it is never left open over a pane
|
|
/// it no longer belongs to: connecting closes it, disconnecting closes it, moving between SFTP and S3
|
|
/// closes it, and losing the vault closes it.
|
|
/// </para>
|
|
/// <para>
|
|
/// The last two are the ones that would rot quietly. A picker surviving a hop to the other tab offers
|
|
/// hosts on a screen showing buckets, and one surviving a lock offers a list that has just been emptied
|
|
/// because its rows carried decrypted secrets.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheFilePickerIsPutAwayByEverythingThatChangesWhatItWouldBePicking()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
shell.Transfers.Attach(vault, knownHosts);
|
|
shell.Transfers.IsChoosingRemote.ShouldBeFalse("the pane opens on its invitation");
|
|
|
|
// Connecting.
|
|
shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
|
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
|
|
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
|
shell.Transfers.IsChoosingRemote.ShouldBeFalse("what is open is the listing now");
|
|
|
|
// Disconnecting, which puts the pane back to the invitation rather than to the form.
|
|
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.IsConnected.ShouldBeFalse(shell.Transfers.Status);
|
|
shell.Transfers.IsChoosingRemote.ShouldBeFalse();
|
|
|
|
// Moving to the other destination, which has a different list behind it.
|
|
shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
|
|
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
|
|
|
|
shell.Transfers.ShowsBucketPicker.ShouldBeTrue();
|
|
shell.Transfers.IsChoosingRemote.ShouldBeFalse("that picker was offering hosts");
|
|
|
|
// Cancelling, which also takes the typed password with it.
|
|
shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
|
|
shell.Transfers.TypedPassword = "hunter2";
|
|
|
|
shell.Transfers.CancelChoosingRemoteCommand.Execute(null);
|
|
|
|
shell.Transfers.IsChoosingRemote.ShouldBeFalse();
|
|
shell.Transfers.TypedPassword.ShouldBeEmpty("a secret nobody asked to keep");
|
|
|
|
// And losing the vault, which empties both lists.
|
|
shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.Transfers.Hosts.ShouldBeEmpty();
|
|
shell.Transfers.IsChoosingRemote.ShouldBeFalse("a form offering a choice between nothing");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The S3 screen with nothing in the keychain, which is where every new account starts and which used to
|
|
/// be a dead end: SELECT BUCKET opened a combo box with nothing in it, and nothing anywhere said that a
|
|
/// bucket is made on the keychain screen. Somebody standing here had arrived at the place a bucket is
|
|
/// used and been shown no route to the place one is created — which is indistinguishable from an
|
|
/// application that cannot add one at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// The assertion that matters is the last pair: pressing the button lands on the keychain <em>with the
|
|
/// editor already open</em>. Navigating to the screen and leaving the user to find + BUCKET among five
|
|
/// buttons would be most of the same problem.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheS3ScreenWithNoBuckets_SaysWhereOneIsMadeAndGoesThere()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
shell.Transfers.Attach(vault, knownHosts);
|
|
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
|
|
|
|
shell.Transfers.ShowsNoBuckets.ShouldBeTrue("nothing has been added to the keychain");
|
|
shell.Transfers.ShowsBucketChoice.ShouldBeFalse("there is nothing to choose between");
|
|
|
|
shell.Transfers.AddBucketCommand.Execute(null);
|
|
|
|
shell.Screen.ShouldBe(ShellScreen.Keychain);
|
|
vault.IsEditingObjectStore.ShouldBeTrue("the route has to land on the editor, not near it");
|
|
|
|
vault.BucketEditorLabel = "Backups";
|
|
vault.BucketEditorBucket = "backups";
|
|
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
|
|
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
|
|
vault.BucketEditorRegion = "eu-west-1";
|
|
|
|
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
|
|
|
|
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
|
|
|
|
shell.Transfers.ShowsNoBuckets.ShouldBeFalse(vault.Status);
|
|
shell.Transfers.ShowsBucketChoice.ShouldBeTrue("the bucket that was just made is the one to open");
|
|
}
|
|
|
|
private async Task UnlockedAsync()
|
|
{
|
|
await EnrolledAndConfirmedAsync();
|
|
|
|
shell.Passphrase = Passphrase;
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
|
}
|
|
|
|
private static async Task AddHostAsync(VaultViewModel vault, string label)
|
|
{
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = label;
|
|
vault.EditorHostname = "db.internal";
|
|
vault.EditorUsername = "deploy";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
private static async Task AddKeyAsync(
|
|
VaultViewModel vault,
|
|
string label,
|
|
string material = "MATERIAL",
|
|
string passphrase = "hunter2")
|
|
{
|
|
vault.NewKeyCommand.Execute(null);
|
|
vault.KeyEditorLabel = label;
|
|
vault.KeyEditorPrivateKey = PrivateKey(material);
|
|
vault.KeyEditorPassphrase = passphrase;
|
|
|
|
await vault.SaveKeyCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <summary>The host row with a given name, which the list orders by label rather than by age.</summary>
|
|
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
|
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
|
|
|
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
|
/// <remarks>
|
|
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
|
/// which is what <c>DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered</c> holds it to. Tests
|
|
/// about something else go through these three helpers, so the two-step is spelled out in one place
|
|
/// rather than in ten.
|
|
/// </remarks>
|
|
private static async Task DeleteSelectedHostAsync(VaultViewModel vault)
|
|
{
|
|
vault.DeleteHostCommand.Execute(null);
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <inheritdoc cref="DeleteSelectedHostAsync" />
|
|
private static async Task DeleteSelectedKeyAsync(VaultViewModel vault)
|
|
{
|
|
vault.DeleteKeyCommand.Execute(null);
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <inheritdoc cref="DeleteSelectedHostAsync" />
|
|
private static async Task DeleteSelectedCredentialAsync(VaultViewModel vault)
|
|
{
|
|
vault.DeleteCredentialCommand.Execute(null);
|
|
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <summary>Points a host at a key through the editor, the way a user would.</summary>
|
|
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
|
|
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
|
|
|
|
/// <summary>Points a host at a stored credential through the same picker.</summary>
|
|
private static Task BindCredentialAsync(VaultViewModel vault, HostRowViewModel host, Guid credentialId) =>
|
|
BindAsync(vault, host, AuthenticationKind.Credential, credentialId);
|
|
|
|
/// <remarks>
|
|
/// One helper for both because there is one control for both, and a test that reached for the binding a
|
|
/// different way than the interface does would stop covering the interface.
|
|
/// </remarks>
|
|
private static async Task BindAsync(
|
|
VaultViewModel vault,
|
|
HostRowViewModel host,
|
|
AuthenticationKind kind,
|
|
Guid entityId)
|
|
{
|
|
vault.SelectedHost = host;
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.IsEditing.ShouldBeTrue("the host editor has to be open for the picker to be populated");
|
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
|
.Single(choice => choice.Kind == kind && choice.EntityId == entityId);
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
private static async Task AddCredentialAsync(
|
|
VaultViewModel vault,
|
|
string label,
|
|
string password = "s3cret",
|
|
string username = "")
|
|
{
|
|
vault.NewCredentialCommand.Execute(null);
|
|
vault.CredentialEditorLabel = label;
|
|
vault.CredentialEditorPassword = password;
|
|
vault.CredentialEditorUsername = username;
|
|
|
|
await vault.SaveCredentialCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <summary>Connects with a renderer attached, which the data plane requires before a session opens.</summary>
|
|
private async Task ConnectWithRendererAsync(VaultViewModel vault)
|
|
{
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same, for a connection that is expected to store its password.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Without the status assertion, and that is the whole reason it is separate. Remembering writes two
|
|
/// items and then pushes them, exactly as saving a host does, so the pass repaints the line with its own
|
|
/// count — leaving "Connected" true of what happened and false of what the line says. What the connection
|
|
/// actually did is asserted on the vault, which is where it is durable.
|
|
/// </remarks>
|
|
private async Task ConnectAndRememberAsync(VaultViewModel vault)
|
|
{
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded");
|
|
}
|
|
|
|
// ---- v5c: settings mode ----
|
|
//
|
|
// The window-level mode that swaps the titlebar, the rail and the page area for settings mode's own —
|
|
// see MainWindowViewModel.EnterSettings and design-notes/v5c-fidelity-notes.md. What is worth proving at
|
|
// this level, with no Avalonia involved, is the state machine itself: entering and leaving preserves
|
|
// wherever the user actually was, switching between settings pages does not forget it, and the two
|
|
// pages that mirror an existing ShellScreen keep every binding written against that screen before this
|
|
// mode existed.
|
|
|
|
/// <remarks>
|
|
/// The core promise of "Back to application": whatever screen a user was on survives a trip through
|
|
/// settings mode untouched, however many pages they visit while they are there.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EnteringAndLeavingSettingsMode_PreservesTheScreenItWasEnteredFrom()
|
|
{
|
|
await ReadyToConnectAsync();
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
|
|
|
shell.EnterSettingsCommand.Execute(SettingsPage.General);
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(SettingsPage.General);
|
|
|
|
// Switching pages inside settings mode must not overwrite the remembered return screen with a
|
|
// settings page of its own — see the remark on MainWindowViewModel.settingsReturnScreen.
|
|
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
|
|
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
|
|
|
|
shell.LeaveSettingsCommand.Execute(null);
|
|
|
|
shell.IsSettingsMode.ShouldBeFalse();
|
|
shell.ActiveSettingsPage.ShouldBeNull();
|
|
shell.Screen.ShouldBe(ShellScreen.Keychain);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Settings mode collapses the terminal the same way any other page does — <see cref="ShellSurface.Page"/>
|
|
/// and <see cref="ShellSurface.Terminal"/> are exclusive by construction — and "Back to application" has
|
|
/// to bring it back rather than leaving the user on a page they never asked for.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task EnteringSettingsModeFromATerminal_CollapsesItAndLeavingRestoresIt()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
shell.IsTerminalSurface.ShouldBeTrue();
|
|
|
|
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
|
|
|
|
shell.IsTerminalSurface.ShouldBeFalse("settings mode occupies the same rectangle a page does");
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
|
|
shell.LeaveSettingsCommand.Execute(null);
|
|
|
|
shell.IsTerminalSurface.ShouldBeTrue();
|
|
shell.IsSettingsMode.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// v5c: <see cref="ShellScreen.Preferences"/> and <see cref="ShellScreen.Vaults"/> are settings pages
|
|
/// now, so anything that still navigates to either — a test written before this wave, the phone's own
|
|
/// hub — is redirected into settings mode on the matching page rather than landing on a screen the
|
|
/// design retired. <see cref="MainWindowViewModel.Screen"/> is kept in step with the two so every
|
|
/// existing binding written against either screen keeps its answer.
|
|
/// <para>
|
|
/// Two <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
|
|
/// and <c>SettingsPage</c> are both <c>internal</c>, and a public theory method may not carry an
|
|
/// internal type in its signature.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public void ShowingPreferences_EntersSettingsModeOnThePreferencesPage() =>
|
|
ShowingAScreenEntersSettingsModeOn(ShellScreen.Preferences, SettingsPage.Preferences);
|
|
|
|
[Fact]
|
|
public void ShowingVaults_EntersSettingsModeOnTheVaultsPage() =>
|
|
ShowingAScreenEntersSettingsModeOn(ShellScreen.Vaults, SettingsPage.Vaults);
|
|
|
|
/// <remarks>
|
|
/// v5c-2: Groups and Tags joined settings mode with no <see cref="ShellScreen"/> counterpart — managing
|
|
/// either has never been its own screen before this wave — so there is no redirect to prove, only that
|
|
/// <see cref="MainWindowViewModel.EnterSettingsCommand"/> reaches each directly.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void EnteringSettingsOnGroups_ShowsTheGroupsPage()
|
|
{
|
|
shell.EnterSettingsCommand.Execute(SettingsPage.Groups);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Groups);
|
|
shell.IsSettingsGroupsPage.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void EnteringSettingsOnTags_ShowsTheTagsPage()
|
|
{
|
|
shell.EnterSettingsCommand.Execute(SettingsPage.Tags);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Tags);
|
|
shell.IsSettingsTagsPage.ShouldBeTrue();
|
|
}
|
|
|
|
private void ShowingAScreenEntersSettingsModeOn(ShellScreen screen, SettingsPage page)
|
|
{
|
|
shell.ShowScreenCommand.Execute(screen);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(page);
|
|
shell.Screen.ShouldBe(screen);
|
|
shell.IsShowingPages.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A caller that names an ordinary screen while settings mode is up is not asking to go back to
|
|
/// wherever settings was entered from — it is asking for that screen, which wins over "Back to
|
|
/// application" restoring anything.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void NavigatingToAnOrdinaryScreenWhileInSettingsMode_LeavesSettingsModeOutright()
|
|
{
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
|
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
|
|
|
shell.IsSettingsMode.ShouldBeFalse();
|
|
shell.Screen.ShouldBe(ShellScreen.Hosts);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The confirmation card moved from the old bare Preferences screen to the Account settings page — see
|
|
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> — and this is the one command both the rail's
|
|
/// popover Logout row and settings mode's own bottom Logout row call, so there is exactly one place the
|
|
/// card is armed from.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SignOutFromPopover_EntersSettingsOnAccountAndArmsTheConfirmation()
|
|
{
|
|
await ReadyToConnectAsync();
|
|
|
|
shell.SignOutFromPopoverCommand.Execute(null);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
|
|
shell.IsConfirmingSignOut.ShouldBeTrue();
|
|
}
|
|
|
|
// ---- v5c-3: the importer, inside settings mode ----
|
|
//
|
|
// Import.dc.html draws the importer over the Preferences page, with SettingsNav still lit on
|
|
// Preferences — so ActiveSettingsPage never actually leaves SettingsPage.Preferences; only
|
|
// MainWindowViewModel.IsImportOpen and IsSettingsPreferencesContentShowing move. See ShowScreen's own
|
|
// translation of ShellScreen.Import, which is the Preferences page's "OPEN IMPORTER" row and every other
|
|
// caller that used to land on the old bare screen.
|
|
|
|
[Fact]
|
|
public void ShowingImport_OpensTheImporterOverThePreferencesPage()
|
|
{
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences, "SettingsNav stays lit on Preferences");
|
|
shell.IsSettingsPreferencesPage.ShouldBeTrue();
|
|
shell.IsImportOpen.ShouldBeTrue();
|
|
shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the importer is drawn over it, not beside it");
|
|
}
|
|
|
|
/// <remarks>The titlebar's own "Back to preferences": closes the importer without leaving settings mode.</remarks>
|
|
[Fact]
|
|
public void CloseImport_ReturnsToPreferencesWithoutLeavingSettingsMode()
|
|
{
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
|
|
|
shell.CloseImportCommand.Execute(null);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences);
|
|
shell.IsImportOpen.ShouldBeFalse();
|
|
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
|
|
}
|
|
|
|
/// <remarks>The importer's own footer Cancel button, wired through ImportViewModel's onCancel delegate.</remarks>
|
|
[Fact]
|
|
public async Task TheImporterScreensCancelButton_ClosesItTheSameWayTheTitlebarDoes()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
|
shell.IsImportOpen.ShouldBeTrue();
|
|
|
|
shell.ImportScreen!.CancelCommand.Execute(null);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue("Cancel backs out to Preferences, not out of Settings altogether");
|
|
shell.IsImportOpen.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Naming a settings page — including Preferences again — while the importer is up is a request for that
|
|
/// page, not for whatever was drawn over it last time. Covers the nav rail's own Preferences row as well
|
|
/// as every other page.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void EnteringAnySettingsPageWhileImportIsOpen_ClosesTheImporter()
|
|
{
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
|
shell.IsImportOpen.ShouldBeTrue();
|
|
|
|
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
|
|
|
|
shell.IsImportOpen.ShouldBeFalse();
|
|
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void LeavingSettingsModeWhileImportIsOpen_ClosesTheImporterToo()
|
|
{
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Import);
|
|
|
|
shell.LeaveSettingsCommand.Execute(null);
|
|
|
|
shell.IsSettingsMode.ShouldBeFalse();
|
|
shell.IsImportOpen.ShouldBeFalse("a stale flag here would reopen the importer the next time Settings is entered");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <see cref="MainWindowViewModel.Issuer"/> is new in v5c, for the Account settings page's SIGN-IN row —
|
|
/// see the property's own remark. <c>MeResponse.Issuer</c> was already being cached into
|
|
/// <c>StoredUnlockMaterial</c> for no reader before this wave; this is the first assertion that it also
|
|
/// reaches the shell.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task UnlockingCarriesTheIssuerOntoTheShell_ForTheAccountPagesSignInRow()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.Issuer.ShouldBe("https://idp.example/realms/dodossh");
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|