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:
@@ -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;
|
||||
|
||||
@@ -95,6 +95,8 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
Substitute.For<IDeviceKeyStore>(),
|
||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||
TimeProvider.System,
|
||||
// Never asked for a session: the palette searches the host list and connects through the
|
||||
// vault's own command, and nothing on this screen transfers a file.
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
@@ -55,6 +59,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!;
|
||||
|
||||
/// <remarks>
|
||||
/// Over a substitute factory that is never asked for a session. Every shape measured here is one the
|
||||
/// screen is in before a connection exists or after one has failed, which is deliberate: the two panes
|
||||
@@ -91,6 +102,17 @@ 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,
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
transfers = new TransfersViewModel(
|
||||
Substitute.For<ISftpSessionFactory>(), TimeProvider.System);
|
||||
|
||||
@@ -104,6 +126,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
await transfers.DisposeAsync();
|
||||
await vault.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
@@ -203,6 +226,88 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The strip along the sidebar's bottom edge with the question in it instead of the three buttons. Its
|
||||
/// tallest shape is a host with a terminal open on it, which adds a disclosure the ordinary case has
|
||||
/// not got — in a 268-pixel column whose middle is a list that has already taken every spare pixel.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Worth measuring rather than assuming, because this is the one card in the application a user cannot
|
||||
/// scroll: the sidebar's only <c>ScrollViewer</c> is inside the host list, so a button pushed past the
|
||||
/// bottom edge here would leave the question unanswerable in either direction.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostSidebarFitsWithADeletionInQuestion()
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.SelectedHost.IsConnected = true;
|
||||
|
||||
vault.DeleteHostCommand.Execute(null);
|
||||
vault.IsConfirmingDeletion.ShouldBeTrue();
|
||||
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
|
||||
|
||||
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
|
||||
/// The gesture is wired in the control rather than bound in the markup, which is exactly the sort of
|
||||
/// wiring that compiles whether or not it is connected to anything — so it is worth a test that
|
||||
/// performs the gesture.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Proved through a connection that is refused before any network is involved. The host is left bound
|
||||
/// to a key that has been deleted, which <c>TryBuildAuthentication</c> turns into a sentence on the
|
||||
/// status line rather than a socket — so what this asserts is that the command ran, with nothing
|
||||
/// timing out to make it flaky.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DoubleClickingAHostConnectsToIt()
|
||||
{
|
||||
var keyId = vault.Keys[0].EntityId;
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedKey = vault.Keys.Single(row => row.EntityId == keyId);
|
||||
vault.DeleteKeyCommand.Execute(null);
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = null;
|
||||
vault.Status = string.Empty;
|
||||
|
||||
await OnTheSidebarAsync((sidebar, window) =>
|
||||
{
|
||||
var row = sidebar.HostList.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.First();
|
||||
|
||||
var centre = row.TranslatePoint(
|
||||
new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the row is not in this window's tree");
|
||||
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
|
||||
vault.Status.ShouldContain(
|
||||
"not in this vault any more",
|
||||
Case.Insensitive,
|
||||
"the double-click has to reach the connect command");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The vault screen ----
|
||||
|
||||
[Fact]
|
||||
@@ -263,6 +368,53 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
|
||||
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
|
||||
/// application.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultScreenFitsWithADeletionInQuestion()
|
||||
{
|
||||
var keyId = vault.Keys[0].EntityId;
|
||||
|
||||
foreach (var host in vault.Hosts.Take(4).ToList())
|
||||
{
|
||||
vault.SelectedHost = host;
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
vault.Section = VaultSection.Keys;
|
||||
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == keyId);
|
||||
|
||||
vault.DeleteSelectedItemCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().HasUsage
|
||||
.ShouldBeTrue("four bound hosts are what makes this the long shape");
|
||||
|
||||
await OnTheVaultAsync((screen, window) =>
|
||||
{
|
||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||
|
||||
// And it says something. A card whose bindings did not resolve would lay out perfectly as three
|
||||
// empty rows, which is the one failure a fit test cannot see: compiled bindings against the
|
||||
// wrong data type are a logged message rather than an exception.
|
||||
var card = screen.GetVisualDescendants().OfType<ConfirmDeleteCard>().ShouldHaveSingleItem();
|
||||
|
||||
var said = string.Join(
|
||||
" ",
|
||||
card.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text));
|
||||
|
||||
said.ShouldContain("key-0", Case.Insensitive, "the question has to name what is going");
|
||||
said.ShouldContain("4 hosts authenticate with it");
|
||||
said.ShouldContain("no undo");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rail is the only way to reach a category, so a button that lands on nothing walls off three
|
||||
/// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they
|
||||
@@ -355,6 +507,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
|
||||
/// column while the listing under it is still showing. A directory, because that is the longer of the
|
||||
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
|
||||
{
|
||||
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||
"2026-07-30",
|
||||
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||
IsDirectory: true);
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
// ---- The chrome ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -447,6 +615,113 @@ 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, an open transfer session adds a line beneath it, and a locked vault carries the longer of
|
||||
// the two warnings.
|
||||
shell.LiveSessionCount = 1;
|
||||
shell.Transfers.IsConnected = true;
|
||||
|
||||
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 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);
|
||||
|
||||
@@ -83,6 +83,69 @@ public sealed class RemotePathTests
|
||||
.ShouldBe("lrwxrwxrwx");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("-rwxr-xr-x", true)]
|
||||
[InlineData("-rw-r-xr--", true)]
|
||||
[InlineData("-rw-r--r-x", true)]
|
||||
[InlineData("-rw-r--r--", false)]
|
||||
[InlineData("----------", false)]
|
||||
public void PosixMode_ReadsAnExecuteBitInAnyTriple(string mode, bool expected)
|
||||
{
|
||||
// Any of the three, not the owner's: the account browsing is not necessarily the owner, and a file
|
||||
// that only the group may run is still a file somebody runs.
|
||||
PosixMode.HasAnyExecuteBit(mode).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("-rw-rw-rw-", true)]
|
||||
[InlineData("-rw-rw-r--", false)]
|
||||
[InlineData("-rw-r--r--", false)]
|
||||
[InlineData("--------w-", true)]
|
||||
public void PosixMode_ReadsTheOthersWriteBit(string mode, bool expected)
|
||||
{
|
||||
// The others triple only. A group-writable file is writable by a named set of people, which is what
|
||||
// groups are for; this asks about the column that names nobody.
|
||||
PosixMode.IsWorldWritable(mode).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("rwxrwxrwx")]
|
||||
[InlineData("?")]
|
||||
public void PosixMode_AnswersFalseForAModeItDidNotWrite(string mode)
|
||||
{
|
||||
// These two decide a row's colour and nothing else. A listing is not worth failing over a string of
|
||||
// the wrong length, and an index off the end of one is how that would happen.
|
||||
PosixMode.HasAnyExecuteBit(mode).ShouldBeFalse();
|
||||
PosixMode.IsWorldWritable(mode).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SftpEntryKind.File, "-rwxr-xr-x", true)]
|
||||
[InlineData(SftpEntryKind.File, "-rw-r--r--", false)]
|
||||
[InlineData(SftpEntryKind.Directory, "drwxr-xr-x", false)]
|
||||
[InlineData(SftpEntryKind.SymbolicLink, "lrwxrwxrwx", false)]
|
||||
public void AnEntry_IsExecutableOnlyWhenItIsAFile(SftpEntryKind kind, string mode, bool expected)
|
||||
{
|
||||
// The x on a directory means "may be searched", which is true of very nearly every directory a host
|
||||
// has. A file browser that marked them all would be marking nothing.
|
||||
Entry(kind, mode).IsExecutable.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SftpEntryKind.File, "-rw-rw-rw-", true)]
|
||||
[InlineData(SftpEntryKind.File, "-rw-r--r--", false)]
|
||||
[InlineData(SftpEntryKind.SymbolicLink, "lrwxrwxrwx", false)]
|
||||
[InlineData(SftpEntryKind.Directory, "drwxrwxrwx", false)]
|
||||
public void AnEntry_IsWorldWritableOnlyWhenItIsAFile(SftpEntryKind kind, string mode, bool expected)
|
||||
{
|
||||
// The two exclusions are the point. Every symbolic link is lrwxrwxrwx and its mode governs nothing —
|
||||
// what may be written is the target, whose mode this listing never fetched. And a world-writable
|
||||
// directory is /tmp, made safe by a sticky bit PosixMode does not render: warning there would be
|
||||
// warning about the half of the mode that is on screen.
|
||||
Entry(kind, mode).IsWorldWritable.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "0 B")]
|
||||
[InlineData(1023, "1023 B")]
|
||||
@@ -96,4 +159,8 @@ public sealed class RemotePathTests
|
||||
// "512.3 MB" claims a precision the figure does not have by the time it is that large.
|
||||
ByteSize.Format(bytes).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>An entry that is nothing but a kind and a mode, which is all these two questions read.</summary>
|
||||
private static SftpEntry Entry(SftpEntryKind kind, string mode) =>
|
||||
new("thing", "/tmp/thing", kind, 0, DateTimeOffset.UnixEpoch, mode);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
@@ -61,6 +62,26 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
/// <summary>Forces the next push to answer <see cref="SyncOperationStatus.Forbidden"/>.</summary>
|
||||
internal bool DenyWrites { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Refuses every cursor a client sends, as a server does whose signing key has been rotated.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A cursor this fake issued itself is refused just as readily, which is the whole point: the position
|
||||
/// is not wrong, the deployment's ability to verify it is gone. A pull carrying no cursor is still
|
||||
/// served, because "from the beginning" is what the real server tells a client to fall back to and is
|
||||
/// the only position it cannot reject.
|
||||
/// </remarks>
|
||||
internal bool RefuseCursors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a pull that carries no cursor as well, which no real server does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Here so a test can prove the client gives up on such a server rather than replaying the log against
|
||||
/// it. Starting over is the only position a client may ask for, so being refused it has no next step.
|
||||
/// </remarks>
|
||||
internal bool RefuseEvenTheBeginning { get; set; }
|
||||
|
||||
/// <summary>Pushes received, so a test can prove a retry did or did not happen.</summary>
|
||||
internal int PushCount { get; private set; }
|
||||
|
||||
@@ -463,8 +484,19 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
private static string EncodeCursor(long sequence) =>
|
||||
"fake-v1:" + sequence.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private static long DecodeCursor(string? cursor)
|
||||
private long DecodeCursor(string? cursor)
|
||||
{
|
||||
if (RefuseCursors && (RefuseEvenTheBeginning || !string.IsNullOrEmpty(cursor)))
|
||||
{
|
||||
// What the real endpoint answers: 400, the invalid-cursor code, and the sentence telling the
|
||||
// client to start over. See DodoSSH.Api.Features.Sync.SyncEndpoints.
|
||||
throw new DodoSshApiException(
|
||||
HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidCursor,
|
||||
"The server returned 400: The sync cursor is not valid for this vault. "
|
||||
+ "Resync from the beginning.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return 0;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Contracts;
|
||||
using static DodoSSH.Client.Sync.Tests.SyncHarness;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
@@ -118,6 +120,70 @@ public sealed class SyncEngineTests
|
||||
after.Cursor.ShouldBe(before.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusedCursor_ReadsTheVaultAgainFromTheBeginning()
|
||||
{
|
||||
// The server's cursor signing key was rotated, or the vault is being served from a restored
|
||||
// database. The stored position is refused for good, so a pass that only reported the 400 would
|
||||
// leave this machine frozen at it — asking once a minute, for ever, and being told the same thing.
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var entityId = await harness.Second.CreateAsync(Host("prod-db"));
|
||||
await harness.Second.SyncAsync();
|
||||
|
||||
harness.Server.RefuseCursors = true;
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.ResyncedFromStart.ShouldBeTrue();
|
||||
report.Pulled.ShouldBe(1);
|
||||
|
||||
// And the point of all of it: the change that was on the far side of the refused position is here.
|
||||
(await harness.First.FindAsync(entityId)).Secret.Label.ShouldBe("prod-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusedCursor_DoesNotStrandWhatIsWaitingToBePushed()
|
||||
{
|
||||
// The half that made this worth recovering from rather than merely reporting. The pull runs first,
|
||||
// so a pass that gave up on the refusal never reached the outbox at all: every edit made on this
|
||||
// machine stayed queued behind a position the server was never going to accept again.
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
|
||||
harness.Server.RefuseCursors = true;
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.ResyncedFromStart.ShouldBeTrue();
|
||||
report.Pushed.ShouldBe(1);
|
||||
harness.Server.Find(entityId).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusalOfTheBeginningItself_IsReportedRatherThanRetried()
|
||||
{
|
||||
// "From the beginning" is the one position a client is allowed to ask for, so a server that
|
||||
// refuses it is one this code cannot reason about. Saying so beats replaying the log against it
|
||||
// until a page bound runs out.
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
harness.Server.RefuseCursors = true;
|
||||
harness.Server.RefuseEvenTheBeginning = true;
|
||||
|
||||
var failure = await Should.ThrowAsync<DodoSshApiException>(
|
||||
async () => await harness.First.SyncAsync());
|
||||
|
||||
failure.Code.ShouldBe(ProblemCodes.InvalidCursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClockSkew_IsRecordedAndNotActedOn()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user