Public Access
693 lines
25 KiB
C#
693 lines
25 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();
|
|
|
|
/// <remarks>
|
|
/// The real factory would need a reachable sshd, which <c>DodoSSH.Client.Ssh.Tests</c> covers against
|
|
/// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
|
|
/// the connect path reachable.
|
|
/// </remarks>
|
|
private readonly FakeSshConnectionFactory ssh = new();
|
|
|
|
private int signInAttempts;
|
|
|
|
private string directory = null!;
|
|
private ClientPaths paths = null!;
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private 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.
|
|
//
|
|
// 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.
|
|
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);
|
|
|
|
// 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,
|
|
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");
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile
|
|
/// and pass every other test, while leaving a discarded vault able to move focus in a locked window.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task LockingStopsTheShellForwardingFocusRequests()
|
|
{
|
|
var vault = await ReadyToConnectAsync();
|
|
|
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
|
|
var requests = 0;
|
|
shell.TerminalSessionOpened += (_, _) => requests++;
|
|
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
requests.ShouldBe(1);
|
|
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
shell.Vault.ShouldBeNull();
|
|
|
|
// The discarded vault is detached, so even a late raise from it reaches nobody.
|
|
await vault.ConnectCommand.ExecuteAsync(null);
|
|
|
|
requests.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnInvalidHost_IsRefusedWithAReason()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = " ";
|
|
vault.EditorHostname = "db.internal";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
|
|
vault.IsEditing.ShouldBeTrue("the editor should stay open so the user can fix it");
|
|
vault.Hosts.ShouldBeEmpty();
|
|
vault.Status.ShouldContain("needs a name");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// This is what earns the right to let a background pass fail silently. The automatic push after a save
|
|
/// is best-effort; the outbox is the durable part. If a failed pass dropped the change, "quiet" would
|
|
/// mean "lost".
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AQueueLeftByAFailedPass_IsStillSentByTheNextSync()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
server.SyncFailure = new HttpRequestException("The server is having a bad day.");
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
server.LiveRowCount.ShouldBe(0, "the automatic push should have failed");
|
|
vault.PendingChanges.ShouldBe(1, "and the change should still be queued");
|
|
vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeTrue();
|
|
|
|
server.SyncFailure = null;
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
server.LiveRowCount.ShouldBe(1);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeFalse();
|
|
vault.Status.ShouldContain("Synchronised");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EditingAHostRoundTripsThroughTheEditor()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
vault.EditSelectedHostCommand.Execute(null);
|
|
|
|
vault.EditorLabel.ShouldBe("prod-db");
|
|
vault.EditorHostname.ShouldBe("db.internal");
|
|
|
|
vault.EditorNotes = "rotate quarterly";
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldHaveSingleItem().Host.Notes.ShouldBe("rotate quarterly");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeletingAHost_RemovesItLocallyAndPushesTheTombstone()
|
|
{
|
|
await UnlockedAsync();
|
|
var vault = shell.Vault!;
|
|
|
|
await AddHostAsync(vault, "prod-db");
|
|
|
|
vault.SelectedHost = vault.Hosts[0];
|
|
await vault.DeleteHostCommand.ExecuteAsync(null);
|
|
|
|
vault.Hosts.ShouldBeEmpty();
|
|
|
|
// Pushed without a second action. A tombstone that sat in the outbox would let the item come back
|
|
// on a machine that synced in the meantime.
|
|
server.LiveRowCount.ShouldBe(0);
|
|
vault.PendingChanges.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SyncingWhileOffline_QueuesRatherThanFailing()
|
|
{
|
|
await EnrolledAndConfirmedAsync();
|
|
await shell.LockCommand.ExecuteAsync(null);
|
|
|
|
// Locking does not drop the connection, so take a fresh shell that never signed in.
|
|
var offline = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
new 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);
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|