Public Access
Pressing Lock nulled and disposed the vault view model and touched nothing else. TerminalWorkspace is injected from App.axaml.cs and outlives every lock, so the SSH connection, the pty and the pump all kept running while the window said "Unlock your vault" — and since0500e43collapsed the WebView while locked, that live session was invisible as well as unstopped. CloseSessionAsync was reachable in production only from DisposeAsync, i.e. shutdown. None of this was written down anywhere, so it was neither a policy nor a bug, which is the actual problem. Shells now deliberately outlive the lock, and every layer says so. The reason to prefer this over making Lock a disconnect: locking is what a person does when they walk away from the machine, which is exactly when a long upgrade, build or transfer is most likely to be in flight. Ending every shell would make Lock a button that destroys work, and the predictable response is to stop pressing it and leave the vault open instead. The idle auto-lock this will grow decides it outright — an unattended timeout that killed a running job would be worse than the exposure it removes. Closing the channel also buys less than it looks: the session was authorised at connect time by a credential the remote verified itself, and no vault key participates in keeping it alive, so locking cannot retroactively un-authorise it any more than removing a member can. Stated honestly rather than implied, because the lock screen is what hides it: - The unlock screen shows how many shells are still connected, and that locking closes the vault and not the connections — so a machine still holding authenticated SSH channels does not present itself as merely "locked". Shown only when there is something to disclose. Quitting is what ends them, and the text admits that. - The Lock button carries the same thing in a tooltip, since its name implies the opposite of what it does to a shell. - README lists it as a third architecture consequence beside non-retroactive revocation, which is the same shape of honest limit; docs/crypto.md §10 records it as a threat-model boundary; TerminalWorkspace and LockAsync carry the argument next to the code. LiveSessionCount deliberately does not count dictionary entries. Nothing removes a session when the remote closes the channel by itself — RunSessionAsync only drops the renderer registration — so sessions.Count would report a shell that exited half an hour ago as still running, on the one screen where a user is deciding whether it is safe to walk away. A completed Run task is what "the shell is gone" actually looks like. While locked the number can only fall, since opening a session needs the vault, so a stale value over-reports rather than under-reports. Both new tests fail when the policy is reverted: the count test times out against sessions.Count, and the shell test reports "workspace.LiveSessionCount should be 1 but was 0" when Lock closes sessions. ShellFlowTests also stops building its workspace with a real SshNetConnectionFactory that nothing ever called, which had made the suite's independence from the network a coincidence rather than a property. Verified by hand with a live shell, which nothing had done: a harness mirroring MainWindow.axaml's 340,* grid with a real NativeWebView, the shipped WebAssets, a real sshd in a container, and an ISshShellSession decorator recording every window-change the remote is actually told about. Across lock and unlock, no window-change reached the remote at all, stty size answered 50 118 before and after, the renderer's own buffer came back byte for byte with the wrapped line intact, and the session stayed live throughout. A control run that never hides the WebView behaves identically, so nothing above is startup or idle behaviour. Keystrokes injected while locked reach nothing: twelve of twelve SendInput events accepted with the harness confirmed as the foreground window, no probe character in the remote's output, and a following Ctrl-U answered BEL, so nothing was queued in the line editor either. A hidden WS_CHILD window is not eligible for keyboard focus, which is what makes surviving the lock defensible rather than merely convenient. Correction to a claim made inf80b3d4: terminal.js's guard comment listed "a host that hides the WebView while the vault is locked" among the paths that reach a degenerate fit. It does not. Collapsing the control hides a native child window without resizing it, so the page still reports paneWidth 840 and paneHeight 760 with unchanged cols and rows, no ResizeObserver callback fires and the fit never runs. Establishing that rather than assuming it: the same cycle with MINIMUM_FITTABLE_PIXELS patched to 0 — the guard fully disabled — is equally clean. The guard is still right for minimising and for a splitter dragged to the edge; it is simply not what makes locking safe, and must not be cited as though it were. Recorded, not fixed: - Nothing closes one terminal from the interface, so a user reading "1 shell is still connected" can only act on it by quitting. CloseSessionAsync is tested and correct; VaultViewModel discards the session id it would need. - A session whose remote exits keeps its ISshConnection, and the thread ShellStream parks, until the process ends. - Suspected and seen once: before the harness waited for the window's scale to settle, a DPI settle pushed a 2202x1328 pane for a window 1180 logical units wide and a later re-push reflowed the wrapped line. Three later runs at RenderScaling 1.00 never showed it, so it is filed as a lead, not a finding. - WebView2 fails to initialise with CO_E_SERVER_EXEC_FAILURE when the host executable sits under a very long path. Cost an hour on the harness; relevant to packaging.
547 lines
19 KiB
C#
547 lines
19 KiB
C#
using DodoSSH.Client.App.ViewModels;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <summary>
|
|
/// The whole path a user walks: sign in, enroll, keep the recovery code, unlock, add a host, sync.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Runs with no Avalonia, no browser and no identity provider, because the view models are plain
|
|
/// observable objects and sign-in is a delegate. What that buys is that the states most likely to be got
|
|
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
|
|
/// rather than by remembering to click through them.
|
|
/// </remarks>
|
|
public sealed class ShellFlowTests : IAsyncLifetime
|
|
{
|
|
private const string Passphrase = "a sufficiently long passphrase";
|
|
|
|
/// <remarks>
|
|
/// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
|
|
/// state machine, not about how expensive the passphrase is to attack.
|
|
/// </remarks>
|
|
private static readonly Argon2Profile CheapProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
|
|
|
private readonly FakeVaultServer server = new();
|
|
|
|
private int signInAttempts;
|
|
|
|
private string directory = null!;
|
|
private ClientPaths paths = null!;
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = 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);
|
|
|
|
var knownHosts = new InMemoryKnownHostStore();
|
|
|
|
// 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.
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(
|
|
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
|
{
|
|
["/terminal"] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
|
|
}),
|
|
|
|
// Idle rather than real: a session in this suite has to be able to exist and to outlive a
|
|
// lock, and none of it should be able to reach a network.
|
|
new IdleSshConnectionFactory(),
|
|
TimeProvider.System);
|
|
|
|
shell = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
SignInAsync,
|
|
TimeProvider.System,
|
|
CheapProfile);
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await workspace.DisposeAsync();
|
|
caches.Dispose();
|
|
|
|
if (Directory.Exists(directory))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AFreshMachine_AsksForAServer()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.IsNeedingServer.ShouldBeTrue();
|
|
shell.IsOnline.ShouldBeFalse();
|
|
|
|
// The migration ran, so the file exists before anyone has signed in to anything.
|
|
File.Exists(paths.CacheFile).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SigningInToAnUnenrolledAccount_AsksForAPassphrase()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsEnrollment);
|
|
shell.IsOnline.ShouldBeTrue();
|
|
shell.AccountName.ShouldBe("Alice Example");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnUnreachableServer_ReportsAndStaysPut()
|
|
{
|
|
server.SignInFailure = new HttpRequestException("No such host is known.");
|
|
|
|
await shell.StartAsync(Token);
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.StatusMessage.ShouldContain("No such host");
|
|
shell.IsBusy.ShouldBeFalse("a failed command must not leave the window disabled");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnInvalidServerUrl_IsRejectedWithoutTouchingTheNetwork()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
|
|
shell.ServerUrl = "not a url";
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.IsOnline.ShouldBeFalse();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Separate from the case above because it is not caught by the same check. <c>Uri.TryCreate</c>
|
|
/// accepts this happily as an absolute URI whose <em>scheme</em> is "localhost" and whose host is
|
|
/// empty, so without an explicit scheme check the mistake surfaces much later as something that reads
|
|
/// like a network fault.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AServerUrlWithNoScheme_SaysSoRatherThanFailingLater()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
|
|
shell.ServerUrl = "localhost:5233";
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsServer);
|
|
shell.IsOnline.ShouldBeFalse();
|
|
shell.StatusMessage.ShouldContain("http://");
|
|
signInAttempts.ShouldBe(0, "a malformed URL must not open a browser");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The shipped default is a value a user is invited to accept unread, so it is worth one assertion.
|
|
/// It was <c>https://localhost:7217</c> — the API's second launch profile — while the README, the
|
|
/// API's appsettings and a plain <c>dotnet run</c> all use HTTP on 5233, and pointing an HTTPS client
|
|
/// at a plaintext port reports a TLS failure that reads like a certificate problem. Nothing failed
|
|
/// except the first thing a new user does.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void TheDefaultServerUrl_IsTheAddressTheApiActuallyServes()
|
|
{
|
|
shell.ServerUrl.ShouldBe("http://localhost:5233");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("short", "short")]
|
|
[InlineData("a sufficiently long passphrase", "a different one")]
|
|
public async Task AWeakOrMismatchedPassphrase_DoesNotEnroll(string entered, string confirmation)
|
|
{
|
|
await SignedInAsync();
|
|
|
|
shell.Passphrase = entered;
|
|
shell.ConfirmPassphrase = confirmation;
|
|
|
|
await shell.EnrollCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.NeedsEnrollment);
|
|
server.EnrollmentCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheRecoveryCodeScreen_CannotBeSkipped()
|
|
{
|
|
// The only moment the code exists. Losing it along with the passphrase means the vault is
|
|
// unrecoverable and there is no server-side reset, so this is the one screen that has to insist.
|
|
await EnrolledAsync();
|
|
|
|
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
|
|
shell.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
|
|
|
|
// Trying to continue without confirming gets nowhere.
|
|
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
|
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
|
|
shell.RecoveryCode.ShouldNotBeNull();
|
|
|
|
shell.RecoveryCodeWrittenDown = true;
|
|
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
|
|
// And it is dropped from memory, not merely hidden. It was never persisted; keeping it in a view
|
|
// model for the rest of the session would undo that.
|
|
shell.RecoveryCode.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AWrongPassphrase_KeepsTheVaultLocked()
|
|
{
|
|
await ReadyToUnlockAsync();
|
|
|
|
shell.Passphrase = "not the passphrase";
|
|
await shell.UnlockCommand.ExecuteAsync(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
shell.Vault.ShouldBeNull();
|
|
shell.StatusMessage.ShouldContain("did not open");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnlockingOpensTheVault()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
shell.State.ShouldBe(ShellState.Unlocked);
|
|
shell.Vault.ShouldNotBeNull();
|
|
shell.Vault.VaultName.ShouldBe("Personal");
|
|
|
|
// Cleared once used, so it is not sitting in a bound property for the rest of the session.
|
|
shell.Passphrase.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARestartUnlocksWithNoNetworkAtAll()
|
|
{
|
|
// The property the whole storage layer exists for, from the shell's point of view. The second
|
|
// shell is given a sign-in delegate that fails if called.
|
|
await EnrolledAndConfirmedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new InMemoryKnownHostStore(),
|
|
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
|
|
TimeProvider.System,
|
|
CheapProfile);
|
|
|
|
await using var _ = offline.ConfigureAwait(false);
|
|
|
|
await offline.StartAsync(Token);
|
|
|
|
offline.State.ShouldBe(ShellState.Locked);
|
|
offline.AccountName.ShouldBe("Alice Example");
|
|
offline.IsOnline.ShouldBeFalse();
|
|
|
|
offline.Passphrase = Passphrase;
|
|
await offline.UnlockCommand.ExecuteAsync(null);
|
|
|
|
offline.State.ShouldBe(ShellState.Unlocked);
|
|
offline.Vault.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddingAHost_ShowsItImmediatelyAndQueuesIt()
|
|
{
|
|
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.ShouldBeTrue();
|
|
row.Badge.ShouldBe("not synced");
|
|
|
|
vault.PendingChanges.ShouldBe(1);
|
|
server.LiveRowCount.ShouldBe(0, "nothing should have been pushed yet");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SyncingSendsTheQueueAndClearsIt()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
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 DeletingAHostRemovesItLocallyBeforeTheServerAgrees()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
await vault.DeleteHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldBeEmpty();
|
|
server.LiveRowCount.ShouldBe(1, "the tombstone has not been pushed yet");
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
server.LiveRowCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
|
{
|
|
await EnrolledAndConfirmedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
// Locking does not drop the connection, so take a fresh shell that never signed in.
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new InMemoryKnownHostStore(),
|
|
(_, _) => throw new InvalidOperationException("unreachable"),
|
|
TimeProvider.System,
|
|
CheapProfile);
|
|
|
|
await using var _ = offline.ConfigureAwait(false);
|
|
|
|
await offline.StartAsync(Token);
|
|
offline.Passphrase = Passphrase;
|
|
await offline.UnlockCommand.ExecuteAsync(null);
|
|
|
|
var vault = offline.Vault!;
|
|
await AddHostAsync(vault, "offline-host");
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Status.ShouldContain("Offline");
|
|
vault.PendingChanges.ShouldBe(1, "the change is kept, not discarded");
|
|
server.PushCount.ShouldBe(0);
|
|
}
|
|
|
|
[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, because Connect waits for a renderer to
|
|
// attach and there is no WebView in this suite. What is under test is what Lock does to a session
|
|
// that exists, not how it came to exist.
|
|
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");
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
private Task<IVaultServer> SignInAsync(Uri serverUrl, CancellationToken cancellationToken)
|
|
{
|
|
// Counted so a test can assert that a rejected URL never got this far. Reaching here means a
|
|
// browser would have opened in the real application.
|
|
signInAttempts++;
|
|
|
|
return server.SignInFailure is { } failure
|
|
? Task.FromException<IVaultServer>(failure)
|
|
: Task.FromResult<IVaultServer>(server);
|
|
}
|
|
|
|
private async Task SignedInAsync()
|
|
{
|
|
await shell.StartAsync(Token);
|
|
await shell.SignInCommand.ExecuteAsync(null);
|
|
shell.State.ShouldBe(ShellState.NeedsEnrollment);
|
|
}
|
|
|
|
private async Task EnrolledAsync()
|
|
{
|
|
await SignedInAsync();
|
|
|
|
shell.Passphrase = Passphrase;
|
|
shell.ConfirmPassphrase = Passphrase;
|
|
|
|
await shell.EnrollCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
private async Task EnrolledAndConfirmedAsync()
|
|
{
|
|
await EnrolledAsync();
|
|
|
|
shell.RecoveryCodeWrittenDown = true;
|
|
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
|
|
|
shell.State.ShouldBe(ShellState.Locked);
|
|
}
|
|
|
|
private Task ReadyToUnlockAsync() => EnrolledAndConfirmedAsync();
|
|
|
|
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);
|
|
}
|
|
}
|