Public Access
This commit is contained in:
@@ -1,5 +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;
|
||||
@@ -223,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]
|
||||
@@ -283,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
|
||||
@@ -375,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>
|
||||
|
||||
@@ -729,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();
|
||||
|
||||
@@ -739,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()
|
||||
{
|
||||
@@ -1005,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);
|
||||
@@ -1387,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];
|
||||
@@ -1412,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);
|
||||
@@ -1541,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);
|
||||
@@ -1573,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();
|
||||
}
|
||||
@@ -1594,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.");
|
||||
@@ -1725,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];
|
||||
@@ -1747,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);
|
||||
@@ -1900,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");
|
||||
@@ -2673,6 +2891,37 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await vault.SaveKeyCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>The host row with a given name, which the list orders by label rather than by age.</summary>
|
||||
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
||||
/// which is what <c>DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered</c> holds it to. Tests
|
||||
/// about something else go through these three helpers, so the two-step is spelled out in one place
|
||||
/// rather than in ten.
|
||||
/// </remarks>
|
||||
private static async Task DeleteSelectedHostAsync(VaultViewModel vault)
|
||||
{
|
||||
vault.DeleteHostCommand.Execute(null);
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="DeleteSelectedHostAsync" />
|
||||
private static async Task DeleteSelectedKeyAsync(VaultViewModel vault)
|
||||
{
|
||||
vault.DeleteKeyCommand.Execute(null);
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="DeleteSelectedHostAsync" />
|
||||
private static async Task DeleteSelectedCredentialAsync(VaultViewModel vault)
|
||||
{
|
||||
vault.DeleteCredentialCommand.Execute(null);
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>Points a host at a key through the editor, the way a user would.</summary>
|
||||
private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
|
||||
BindAsync(vault, host, AuthenticationKind.SshKey, keyId);
|
||||
|
||||
Reference in New Issue
Block a user