Public Access
Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s
ci / build and test (push) Failing after 2s
Three files conflicted, and two of the resolutions are more than a choice of side. QuickConnectTests had both branches fixing the same build break — main's M2 merge left the shell's constructor with an ISftpSessionFactory nobody passed. Main's version wins because it carries a comment saying why the palette never needs a session. VaultSession's conflict is adjacent edits: main added the remembered sign-in members and this branch changed SyncAsync's summary from "the active vault" to "one vault". Both kept. VaultViewModel is the one that matters. Main taught the background pass to report a sync that had to start over, on the grounds that a machine which silently re-read a whole vault has had something happen to it; this branch turned a pass into one report per readable vault. Taking either side alone would have lost the other, so ResyncedFromStart is now one of the conditions IsWorthReporting checks, per vault. Merging also broke something neither branch could have caught alone, and the build would not have said a word. SyncOnceAsync cleared LastSyncFailed unconditionally, which was right while a pass was one vault and a failure was an exception that never reached that line. A failure is now a report — one unreachable team vault must not stop the others syncing — so the flag was being cleared over a vault that had just failed, lighting the titlebar SYNCED. It is computed from the report instead, in the one place both callers go through, so the manual command gets it as well as the loop. The background pass still swallows the message and keeps the fact, which is what AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to. Two comments the auto-merge left describing a world with one vault in it: the SCOPES rail's, which said team vaults are refused by the access service, and the host sidebar's "One heading, for one vault".
This commit is contained in:
@@ -77,6 +77,16 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
|
||||
/// <inheritdoc />
|
||||
public SyncOptions SyncOptions => SyncOptions.Default;
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token this "connection" holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Settable, because rotation is the half of remembering a sign-in that is easy to get wrong: a shell
|
||||
/// that persisted the token it first saw would leave a rotating provider refusing the next launch. A
|
||||
/// test changes this and asserts the new value reaches the cache.
|
||||
/// </remarks>
|
||||
public string? RefreshToken { get; set; } = "refresh-token-1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||
@@ -42,6 +43,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
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!;
|
||||
@@ -111,7 +124,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
SignInAsync,
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile);
|
||||
CheapProfile,
|
||||
ResumeAsync);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -715,7 +729,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedHostAsync(vault);
|
||||
|
||||
vault.Hosts.ShouldBeEmpty();
|
||||
|
||||
@@ -725,6 +739,219 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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()
|
||||
{
|
||||
@@ -991,7 +1218,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
|
||||
vault.SelectedKey = vault.Keys[0];
|
||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedKeyAsync(vault);
|
||||
|
||||
vault.Keys.ShouldBeEmpty();
|
||||
server.LiveRowCount.ShouldBe(0);
|
||||
@@ -1373,7 +1600,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||
|
||||
vault.SelectedKey = vault.Keys[0];
|
||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedKeyAsync(vault);
|
||||
vault.Keys.ShouldBeEmpty();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
@@ -1398,7 +1625,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await BindKeyAsync(vault, vault.Hosts[0], keyId);
|
||||
|
||||
vault.SelectedKey = vault.Keys[0];
|
||||
await vault.DeleteKeyCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedKeyAsync(vault);
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
@@ -1527,7 +1754,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddCredentialAsync(vault, "prod deploy");
|
||||
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedCredentialAsync(vault);
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
server.LiveRowCount.ShouldBe(0);
|
||||
@@ -1559,7 +1786,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
vault.SelectedCredential.ShouldBeNull();
|
||||
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
// 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();
|
||||
}
|
||||
@@ -1580,7 +1812,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
// And a kind with nothing in it is left out rather than reported as zero.
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedCredentialAsync(vault);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
|
||||
@@ -1711,7 +1943,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
||||
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedCredentialAsync(vault);
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
@@ -1733,7 +1965,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await BindCredentialAsync(vault, vault.Hosts[0], credentialId);
|
||||
|
||||
vault.SelectedCredential = vault.Credentials[0];
|
||||
await vault.DeleteCredentialCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedCredentialAsync(vault);
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
@@ -1886,7 +2118,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
await vault.DeleteHostCommand.ExecuteAsync(null);
|
||||
await DeleteSelectedHostAsync(vault);
|
||||
|
||||
vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
|
||||
"the pin outlives the host, and the list has to admit it");
|
||||
@@ -2032,6 +2264,46 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
: 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);
|
||||
@@ -2216,6 +2488,296 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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 ----
|
||||
|
||||
[Fact]
|
||||
@@ -2329,6 +2891,37 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user