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:
@@ -55,6 +55,20 @@ internal static class LayoutHarness
|
||||
/// <inheritdoc cref="TitleBarHeight" />
|
||||
internal const double StatusBarHeight = 24;
|
||||
|
||||
/// <summary>
|
||||
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// From <c>Border.card</c> in <c>App.axaml</c> — <c>MaxWidth</c> 520 and <c>Padding</c> 24 — because the
|
||||
/// cards themselves live inside <c>MainWindow.axaml</c>, which cannot be laid out here at all. Measuring
|
||||
/// a card's contents at the size the card gives them is the closest this harness can get to the unlock
|
||||
/// screen, and it is the half that has something to blow: the frame is fixed and the contents are not.
|
||||
/// </remarks>
|
||||
internal const double CardContentWidth = 520 - (2 * 24);
|
||||
|
||||
/// <inheritdoc cref="CardContentWidth" />
|
||||
internal static double CardContentHeight => ScreenHeight - (2 * 24);
|
||||
|
||||
/// <summary>The height a screen actually gets at the window's minimum.</summary>
|
||||
internal static double ScreenHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.Views;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
@@ -54,6 +55,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
|
||||
/// <remarks>
|
||||
/// Constructed and never started: the sign-out card binds to the shell rather than to a vault, and what
|
||||
/// it shows comes from properties a fresh one already answers. Starting it would migrate a cache and
|
||||
/// read a profile, neither of which any rectangle here depends on.
|
||||
/// </remarks>
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -82,12 +90,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
// every sync pass out of a suite that is only measuring rectangles.
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-layout-{Guid.CreateVersion7():N}")),
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
new UnavailableDeviceKeyStore(),
|
||||
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||
TimeProvider.System,
|
||||
CheapProfile);
|
||||
|
||||
await SeedAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
await vault.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
@@ -363,6 +382,111 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- The unlock screen ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The card a locked application is entirely made of, in its two shapes: an ordinary launch, and one
|
||||
/// where shells were left running and the disclosure about them appears. It was extracted from
|
||||
/// <c>MainWindow.axaml</c> to be measurable at all — that window cannot be shown here, so anything
|
||||
/// inside it is unmeasured by construction — and it is the card with the least room to spare.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The status line is set to something long on purpose. It is bound to whatever the last thing that
|
||||
/// happened said, and the longest of those is a sentence about an expired sign-in, which is exactly the
|
||||
/// message this screen is most likely to be carrying on the launch where the extra rows also appear.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2)]
|
||||
public async Task TheUnlockCardFitsTheCardItIsShownIn(int liveSessions)
|
||||
{
|
||||
shell.LiveSessionCount = liveSessions;
|
||||
shell.CanUnlockWithDevice = true;
|
||||
shell.StatusMessage = "Your sign-in has expired, so this machine is offline: the token endpoint "
|
||||
+ "returned 400: Invalid refresh token. Sign in again from Preferences to start syncing.";
|
||||
|
||||
await MeasureCardAsync(new UnlockCard());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheUnlockBoxTakesEnterAsUnlock()
|
||||
{
|
||||
// Enter is how everybody finishes typing a password, and this screen had no answer to it until the
|
||||
// gesture below existed: the passphrase box is where locking puts the keyboard, so the one thing a
|
||||
// user does without thinking did nothing at all until they found the button.
|
||||
//
|
||||
// The gesture is what can be asserted; that pressing it unlocks is ShellFlowTests' business,
|
||||
// against the command this binds to.
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var card = new UnlockCard { DataContext = shell };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
var binding = card.PassphraseBox.KeyBindings.ShouldHaveSingleItem();
|
||||
|
||||
binding.Gesture.ShouldBe(new KeyGesture(Key.Enter));
|
||||
binding.Command.ShouldBeSameAs(shell.UnlockCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- The sign-out confirmation ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one new card that has to share a screen with an unlock prompt, and the only one whose height
|
||||
/// depends on what it is saying: the warning is a sentence about the outbox, and the disclosure about
|
||||
/// shells left running appears only when there are some. Both are wrapped paragraphs, which is the
|
||||
/// shape that grows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured in the space a card gives its contents rather than inside <c>MainWindow</c>, which cannot
|
||||
/// be laid out here — see <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c>. What that leaves
|
||||
/// unchecked is the card's own frame, which is a fixed border and a constant padding.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSignOutCardFitsTheCardItIsShownIn()
|
||||
{
|
||||
// Its tallest shape: a shell left running adds a disclosure box that an ordinary sign-out does not
|
||||
// have, and a locked vault carries the longer of the two warnings.
|
||||
shell.LiveSessionCount = 1;
|
||||
|
||||
await MeasureCardAsync(new SignOutCard());
|
||||
}
|
||||
|
||||
/// <summary>Lays a setup-screen card out in the space <c>Border.card</c> gives its contents.</summary>
|
||||
private Task MeasureCardAsync(Control card) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
card.DataContext = shell;
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>Lays the sidebar out at the width the hosts screen gives it.</summary>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in a machine may resume, and what emptying the cache does to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two behaviours meet here for a reason: the refresh token is the one thing in this cache that is a
|
||||
/// credential for the <em>account</em> rather than for the vault, so both halves of its life — sealed
|
||||
/// while it is kept, gone when the user signs out — belong under one test class.
|
||||
/// </remarks>
|
||||
public sealed class RememberedSignInTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task ARememberedTokenRoundTrips()
|
||||
{
|
||||
using var harness = await CacheHarness.CreateAsync();
|
||||
|
||||
var store = Store(harness);
|
||||
|
||||
(await store.ReadAsync(Token)).ShouldBeNull("nothing has been remembered yet");
|
||||
|
||||
await store.SaveAsync("refresh-token-1", Token);
|
||||
|
||||
(await store.ReadAsync(Token)).ShouldBe("refresh-token-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RememberingAgain_ReplacesRatherThanAdds()
|
||||
{
|
||||
// What a rotating provider does on every refresh. A second row would be a constraint violation;
|
||||
// keeping the first would leave the next launch presenting a token the provider has retired.
|
||||
using var harness = await CacheHarness.CreateAsync();
|
||||
|
||||
var store = Store(harness);
|
||||
|
||||
await store.SaveAsync("refresh-token-1", Token);
|
||||
await store.SaveAsync("refresh-token-2", Token);
|
||||
|
||||
(await store.ReadAsync(Token)).ShouldBe("refresh-token-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnotherUsersCacheKey_DoesNotOpenIt()
|
||||
{
|
||||
// The whole reason this is sealed rather than stored. A cache file lifted off a machine cannot be
|
||||
// made to yield an account credential without the key that only an unlocked vault holds.
|
||||
using var owner = await CacheHarness.CreateAsync();
|
||||
using var stranger = await CacheHarness.CreateAsync();
|
||||
|
||||
await Store(owner).SaveAsync("refresh-token-1", Token);
|
||||
|
||||
var strangersView = new RememberedSignInStore(
|
||||
owner.Factory, stranger.Protector, CacheHarness.UserId, TimeProvider.System);
|
||||
|
||||
(await strangersView.ReadAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgettingIt_LeavesNothingToResume()
|
||||
{
|
||||
using var harness = await CacheHarness.CreateAsync();
|
||||
|
||||
var store = Store(harness);
|
||||
|
||||
await store.SaveAsync("refresh-token-1", Token);
|
||||
await store.ForgetAsync(Token);
|
||||
|
||||
(await store.ReadAsync(Token)).ShouldBeNull();
|
||||
|
||||
// And forgetting what is not there is not an error: it runs on a sign-out from a machine that
|
||||
// never remembered one.
|
||||
await store.ForgetAsync(Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResettingTheCache_EmptiesEveryTableAndKeepsTheSchema()
|
||||
{
|
||||
// What signing out does on disk. Every row goes — the profile an unlock reads, the item mirror,
|
||||
// the outbox, the remembered sign-in — and the database is immediately usable again, because the
|
||||
// application has to be able to be set up afresh without being restarted.
|
||||
using var harness = await CacheHarness.CreateAsync();
|
||||
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Unlock.SaveAsync(Material(), Token);
|
||||
await harness.Items.SaveAsync(CacheHarness.Item(entityId), Token);
|
||||
await harness.Outbox.QueueAsync(CacheHarness.Change(entityId), Token);
|
||||
await Store(harness).SaveAsync("refresh-token-1", Token);
|
||||
|
||||
await harness.Factory.ResetAsync(Token);
|
||||
|
||||
(await harness.Unlock.ReadAsync(Token)).ShouldBeNull("the profile is what makes a machine enrolled");
|
||||
|
||||
(await harness.Items
|
||||
.ListAsync(CacheHarness.VaultId, SyncEntityType.Host, includeDeleted: true, Token))
|
||||
.ShouldBeEmpty();
|
||||
(await harness.Outbox.ListAllAsync(CacheHarness.VaultId, Token)).ShouldBeEmpty();
|
||||
(await Store(harness).ReadAsync(Token)).ShouldBeNull();
|
||||
|
||||
// Usable, not merely empty: writing to it again must not need a migration.
|
||||
await harness.Unlock.SaveAsync(Material(), Token);
|
||||
(await harness.Unlock.ReadAsync(Token)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static RememberedSignInStore Store(CacheHarness harness) =>
|
||||
new(harness.Factory, harness.Protector, CacheHarness.UserId, TimeProvider.System);
|
||||
|
||||
private static StoredUnlockMaterial Material() =>
|
||||
new(
|
||||
"https://dodossh.example",
|
||||
CacheHarness.UserId,
|
||||
"https://idp.example",
|
||||
"alice",
|
||||
"alice@example.com",
|
||||
"Alice",
|
||||
KeyGeneration: 1,
|
||||
WrappedPrivateKey: [1, 2, 3, 4],
|
||||
new KdfParameters("argon2id", [5, 6, 7, 8], 262144, 4, 1),
|
||||
DateTimeOffset.FromUnixTimeSeconds(1_750_000_000));
|
||||
}
|
||||
Reference in New Issue
Block a user