Public Access
Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes Enter, which is the gesture everybody makes after typing a password and which did nothing until they found the button. Signing in survives a relaunch. The refresh token is kept in the local cache, sealed under the vault's own cache key, so a later launch resumes the session through the refresh grant with no browser and nobody present — and because it is sealed under that key, only an unlocked vault can resume it. A locked client therefore cannot reach the server at all, which is a consequence worth stating rather than working around; docs/crypto.md §3.2 records it. Every sync pass asks the shell for a connection rather than reading one captured at unlock, so a laptop that unlocked on a train is online within a minute of finding a network, with nothing pressed. Unlocking itself still never waits on a socket. Signing out empties this machine: the profile, the cached items, the outbox and this machine's device key, with the account's row withdrawn when the server can be reached. It asks first and says what it costs — the outbox count when the vault is open, an admission that it cannot be counted when it is not, and the shells that keep running either way. The vault is on the server and is untouched, which is what makes the same button the only honest answer to a forgotten passphrase, so it is on the unlock screen as well as in preferences. It cannot end the session at the identity provider, and says so. Two defects surfaced on the way. The synchronisation pass that runs when the vault opens never ran at all: the loop is started from inside the unlock command, so the busy flag it yields to was raised by that command — the first sync was a minute late on every launch. And signing in from preferences while unlocked threw an unlock screen over an open vault whose keys were still in memory. The unlock card and the new confirmation live in their own controls because MainWindow cannot be laid out headless, so markup left inside it is markup no test can measure; both are now measured at the window's minimum size in the shapes that grow. What is still unverified is the composed window itself.
This commit is contained in:
@@ -77,6 +77,16 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
/// <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!;
|
||||
@@ -110,7 +123,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
deviceKeys,
|
||||
SignInAsync,
|
||||
TimeProvider.System,
|
||||
CheapProfile);
|
||||
CheapProfile,
|
||||
ResumeAsync);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -1966,7 +1980,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-web-01");
|
||||
await AddHostAsync(vault, "prod-web-02");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "prod-web-01");
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-web-01", StringComparison.Ordinal));
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
|
||||
@@ -1999,7 +2014,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "stage-web");
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "stage-web", StringComparison.Ordinal));
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
@@ -2027,6 +2043,45 @@ 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,
|
||||
CheapProfile,
|
||||
resume);
|
||||
|
||||
private async Task SignedInAsync()
|
||||
{
|
||||
await shell.StartAsync(Token);
|
||||
@@ -2210,6 +2265,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);
|
||||
}
|
||||
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await EnrolledAndConfirmedAsync();
|
||||
|
||||
Reference in New Issue
Block a user