Public Access
Three triggers: once when the vault opens, straight after any local change, and every minute while it stays open. The Sync button stays, because someone just handed a credential wants to know now rather than within the minute, but nothing depends on it being pressed any more. A background pass is deliberately not the button's code path. Routing it through RunAsync would raise the busy flag every minute — disabling Connect and Save for the duration — and repaint the status line over whatever the user was reading. So it is quiet: the status changes only when a pass actually moved an item or produced something needing attention, and a pass is skipped outright while a command is running rather than queueing behind it. Both guards are covered; removing either fails a test. A shared semaphore serialises every pass, taken with a zero timeout rather than awaited — a pass arriving while another runs has nothing to add by waiting, and queueing them would turn a slow server into a backlog of identical work. Failures are swallowed, which is right in exactly this one place: a laptop closed all afternoon would otherwise replace the status line with a socket error once a minute. It is quiet rather than hidden — the account bar already shows when there is no connection, and pressing Sync reports the real reason. What earns that is the outbox: a test proves a change left queued by a failed pass is still sent by the next sync, so quiet never means lost. Two existing tests asserted the opposite behaviour — that a save queued and pushed nothing until Sync was pressed — and were rewritten rather than deleted; the local-first guarantee they were really protecting is that the list updates with no server, which the offline test still covers. Two things the tests caught in my own work. ReloadAsync had to be split out of LoadAsync because rebuilding the list repainted the status line unconditionally, which made "the background pass is quiet" false on the one path that mattered. And the yields-to-a-command test was vacuous as first written: saving pushes, so there was no pending change left and the assertion held with the guard deleted. It now fails the automatic push first to arrange a real queue.
573 lines
20 KiB
C#
573 lines
20 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()),
|
|
}),
|
|
new SshNetConnectionFactory(knownHosts),
|
|
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();
|
|
}
|
|
|
|
/// <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");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnInvalidHost_IsRefusedWithAReason()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = " ";
|
|
vault.EditorHostname = "db.internal";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.IsEditing.ShouldBeTrue("the editor should stay open so the user can fix it");
|
|
vault.Hosts.ShouldBeEmpty();
|
|
vault.Status.ShouldContain("needs a name");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// This is what earns the right to let a background pass fail silently. The automatic push after a save
|
|
/// is best-effort; the outbox is the durable part. If a failed pass dropped the change, "quiet" would
|
|
/// mean "lost".
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AQueueLeftByAFailedPass_IsStillSentByTheNextSync()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
server.LiveRowCount.ShouldBe(0, "the automatic push should have failed");
|
|
vault.PendingChanges.ShouldBe(1, "and the change should still be queued");
|
|
vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeTrue();
|
|
|
|
server.SyncFailure = null;
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
server.LiveRowCount.ShouldBe(1);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeFalse();
|
|
vault.Status.ShouldContain("Synchronised");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAHostRoundTripsThroughTheEditor()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorLabel.ShouldBe("prod-db");
|
|
vault.EditorHostname.ShouldBe("db.internal");
|
|
|
|
vault.EditorNotes = "rotate quarterly";
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.Notes.ShouldBe("rotate quarterly");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeletingAHost_RemovesItLocallyAndPushesTheTombstone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
await vault.DeleteHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldBeEmpty();
|
|
|
|
// Pushed without a second action. A tombstone that sat in the outbox would let the item come back
|
|
// on a machine that synced in the meantime.
|
|
server.LiveRowCount.ShouldBe(0);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
|
{
|
|
await EnrolledAndConfirmedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
// Locking does not drop the connection, so take a fresh shell that never signed in.
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new 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);
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
}
|