Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/VaultColumnLayoutTests.cs
T
jaap-jan d162271a45
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
Show the host keys this vault has approved
Trust was created by the connect prompt and withdrawn from one host's editor, so
a pin for a host that had since been deleted or re-addressed was unreachable
from the interface entirely. It went on refusing connections and nothing in the
application would admit it was there. Two of the four recorded debts were really
this one: leftover pins, and no list to see them in.

A fourth section in the vault column, and the first that adding one has been
cheap for — three edits and two layout tests, which is what #8 and #9 were for.

No editor and no Add, which makes it the only section with neither. A pin is not
something anybody writes: it appears when somebody approves a fingerprint at the
moment of connecting, which is the one place a person can actually check it
against what the operator published. A form for typing one in would be a form
for pasting whatever a man in the middle just offered. So the section exists to
show and to withdraw, which is exactly what was missing.

The fingerprint is shown in full, wrapped, in a monospace line. The only thing
anybody does with one is compare it against a fingerprint an operator published,
and half of one cannot be compared — it can only be glanced at, which is the
habit pinning exists to replace. Nothing here is secret; a host key fingerprint
is published on purpose.

A pin no host in this vault dials is badged rather than hidden or deleted. That
is the leftover the debt was about, and keeping it is still right: the address
may be reached by something without a bookmark, and trust is about the endpoint
rather than the bookmark. The badge is a hint and not a verdict, which is why
nothing acts on it. Matched case-insensitively, because a host name is, and
because a list that called DB.internal unused next to a host saved as
db.internal would be inviting somebody to delete trust they rely on.

Forgetting goes through the same ForgetAsync as the host editor's button, which
withdraws every pin for the address rather than the selected row. Deliberate:
somebody who has stopped trusting a machine has not decided to keep trusting one
of its keys, and a second pin under another algorithm would go on being offered
at the next handshake — which reads as a withdrawal that did not work. The
status line says how many went, and the change is pushed immediately, because
the other machines are the ones still refusing to connect to a rebuilt server.

The list is read through the repository rather than through VaultKnownHostStore,
whose snapshot is shaped for the SSH handshake: one pin per endpoint,
deduplicated, no entity ids. This list has to show duplicates, because a
duplicate is one of the things worth seeing.

Two mutations, both caught: calling every pin dialled (3 tests), and defaulting
the selection to the first row (1) — the same hazard as the credential list,
since Forget acts on the selection.

The selector now holds four buttons in 340 pixels, and TheSelectorIsBigEnoughToClick
measures how much of that they use rather than leaving a fifth section to
discover it as "a button falls outside the window".

936 tests green across 16 projects, 6 of them new. Zero warnings, format clean.

Not verified: how the section looks. It joins the list in outstanding item #7.
2026-07-30 17:44:33 +02:00

436 lines
18 KiB
C#

using Avalonia.Controls;
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// Whether the vault column fits in the space the window gives it.
/// </summary>
/// <remarks>
/// <para>
/// The column is 340 pixels wide and holds a list and an editor per item type, of which it shows one type at a
/// time. This suite is the measurement behind that arrangement: the column used to stack both types and keep
/// itself from clipping its own Save button with a state rule — one editor open at a time — and that rule was
/// added on the strength of an argument. The argument was right about the stacked column and is now moot,
/// which is a thing this suite found rather than assumed. See
/// <see cref="BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut" />.
/// </para>
/// <para>
/// One test per section, and one per section with its editor open, because that is the full set of shapes a
/// user can put this column into. A third section will add two more.
/// </para>
/// <para>
/// A real <c>VaultViewModel</c> over a real unlocked vault, rather than a stand-in. Compiled bindings resolve
/// against the declared data type, so a stand-in would have to be the same type anyway — and the editors'
/// height depends on real content: a key with a real armour block in the box is taller than an empty one.
/// </para>
/// </remarks>
public sealed class VaultColumnLayoutTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private readonly VaultKnownHostStore knownHosts = new();
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultSession session = null!;
private VaultViewModel vault = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"layout-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
session = outcome.Session!;
// Never started and never connected through: the column's layout does not depend on the terminal, and
// the substitute is here only because the view model's constructor asks for one.
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
// Offline. A null connection is what the column shows on a laptop with no network, and it keeps every
// sync pass out of a suite that is only measuring rectangles.
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
await SeedAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task TheHostsSectionFitsWithNoEditorOpen()
{
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheHostsSectionFitsWithItsEditorOpen()
{
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheKeysSectionFitsWithNoEditorOpen()
{
vault.ShowSectionCommand.Execute(VaultSection.Keys);
vault.ShowsKeys.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheKeysSectionFitsWithItsEditorOpen()
{
// The tall one: a private key needs a real text area, and this editor is what the key list used to
// hide itself and cap its own height for. Both workarounds are gone, so this measurement is now the
// only thing saying they were not needed.
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
vault.ShowsKeys.ShouldBeTrue("opening an editor has to bring its own section into view");
vault.KeyEditorPrivateKey = string.Join(
'\n',
Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6));
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheCredentialsSectionFitsWithNoEditorOpen()
{
vault.ShowSectionCommand.Execute(VaultSection.Credentials);
vault.ShowsCredentials.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheCredentialsSectionFitsWithItsEditorOpen()
{
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue();
vault.ShowsCredentials.ShouldBeTrue("opening an editor has to bring its own section into view");
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The only section with no editor, so it has only the one shape — but the tallest rows, because each
/// carries a full fingerprint on a wrapped monospace line rather than a one-word description.
/// </remarks>
[Fact]
public async Task TheHostKeysSectionFits()
{
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
vault.ShowsKnownHosts.ShouldBeTrue();
vault.KnownHostPins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The host editor is the one a third item type made taller: its authentication picker is now a ComboBox
/// with a two-line-capable item template, and the section it sits in is the only one holding a
/// <c>NumericUpDown</c>, a <c>CheckBox</c> and two paragraphs of hint text. Measured with the picker
/// populated, because an empty ComboBox is shorter than one showing a qualifier beside a label.
/// </remarks>
[Fact]
public async Task TheHostEditorFitsWithTheAuthenticationPickerFull()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorAuthenticationChoices.Count
.ShouldBeGreaterThan(1, "the picker has to be populated for this to measure anything");
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut()
{
// This test used to assert the opposite, and its own comment said that if it ever started passing the
// rule it justified had become unnecessary. That has happened, and this is the record of it: the two
// editors are in different sections now and only one section is laid out, so the sizing argument for
// one-editor-at-a-time is dead.
//
// The rule itself is not, and AnEditorIsInTheWay says why — an open key editor holds a pasted private
// key, and moving on would leave it in a form nobody can see. That is a state rule with a state
// reason, so it belongs in the shell's tests and not here. This suite's job was the sizing claim, and
// the honest thing to do with a measurement that has flipped is to keep measuring it.
vault.IsEditing = true;
vault.IsEditingKey = true;
await MeasureAsync(faults => faults.ShouldBeEmpty(
"one section at a time means two open editors are never laid out together"));
vault.Section = VaultSection.Keys;
await MeasureAsync(faults => faults.ShouldBeEmpty(
"and the same holds from the other side, where the taller editor is the visible one"));
}
/// <remarks>
/// <para>
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes the
/// keyboard off the terminal's native child window first and then focuses this target, so a target that
/// cannot take focus leaves the user with no focused element and no way back except the mouse.
/// </para>
/// <para>
/// Which is why this asserts that focus was <i>taken</i> rather than that the right control was named.
/// Naming is the cheap half and it was already right; taking it was not — a <c>ListBox</c> is not focusable
/// by default, so this call returned false against the column as it stood and the shipped release-the-
/// keyboard path did nothing. Two ways to fail, and only the assertion that runs the call sees both: a
/// control in the section that is not showing is collapsed, and <c>Focus()</c> on a collapsed control is a
/// no-op that is not replayed when it is revealed.
/// </para>
/// </remarks>
[Fact]
public async Task TheKeyboardTargetIsTheListThatIsOnScreenAndItTakesFocus()
{
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.HostList);
column.KeyboardTarget.Focus().ShouldBeTrue("the hosts section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.Keys);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.KeyList);
column.KeyboardTarget.Focus().ShouldBeTrue("the keys section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.Credentials);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.CredentialList);
column.KeyboardTarget.Focus().ShouldBeTrue("the credentials section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.KnownHostList);
column.KeyboardTarget.Focus().ShouldBeTrue("the host keys section is showing");
});
}
/// <remarks>
/// The same call in the state the section rule allows: an editor open, its own list still on screen behind
/// it. The key list used to collapse itself whenever its editor opened, so a target that followed the
/// section would have been a no-op in exactly the state a user is most likely to leave the terminal in.
/// </remarks>
[Fact]
public async Task TheKeyboardTargetStillTakesFocusWithAnEditorOpen()
{
vault.NewKeyCommand.Execute(null);
await OnTheColumnAsync((column, _) =>
{
column.KeyList.IsEffectivelyVisible.ShouldBeTrue();
column.KeyboardTarget.Focus().ShouldBeTrue();
});
}
/// <remarks>
/// The claim the whole arrangement rests on, and the one nothing else here would notice breaking: two
/// sections left visible at once would overlap in the row they share rather than clip, so every fit test
/// above would still pass while the column showed one list through another.
/// </remarks>
[Fact]
public async Task OnlyOneSectionIsOnScreenAtOnce()
{
await AssertOnlyVisibleAsync(VaultSection.Hosts);
await AssertOnlyVisibleAsync(VaultSection.Keys);
await AssertOnlyVisibleAsync(VaultSection.Credentials);
await AssertOnlyVisibleAsync(VaultSection.KnownHosts);
}
/// <summary>Shows one section and checks that it is the only one a user can see.</summary>
private async Task AssertOnlyVisibleAsync(VaultSection section)
{
vault.Section = section;
await OnTheColumnAsync((column, _) =>
{
var lists = new Dictionary<VaultSection, ListBox>
{
[VaultSection.Hosts] = column.HostList,
[VaultSection.Keys] = column.KeyList,
[VaultSection.Credentials] = column.CredentialList,
[VaultSection.KnownHosts] = column.KnownHostList,
};
foreach (var (owner, list) in lists)
{
list.IsEffectivelyVisible.ShouldBe(
owner == section,
$"{owner} showing while {section} is selected");
}
});
}
/// <remarks>
/// The selector is the only way to reach a section, so a click that lands on nothing is a column with one
/// half of it walled off. Its buttons are covered by every fit test above — the harness treats a
/// <see cref="Button"/> as interactive — but that only proves they are inside the window. This proves they
/// are the size a pointer can find, which a zero-height row of buttons in a collapsed border would not be.
/// </remarks>
[Fact]
public async Task TheSelectorIsBigEnoughToClick()
{
await OnTheColumnAsync((column, _) =>
{
var buttons = column.SectionSelector.Children.OfType<Button>().ToList();
buttons.Count.ShouldBe(4, "one per section that exists");
foreach (var button in buttons)
{
button.Bounds.Height.ShouldBeGreaterThan(20);
button.Bounds.Width.ShouldBeGreaterThan(40);
}
// How much room a fifth section would have. The row is a horizontal StackPanel in a 340-pixel
// column, so the four labels are close to filling it — and the fit tests above would catch an
// overflow only as "a button falls outside the window", which reads as a mysterious layout fault
// rather than as "the selector has run out of room". Stated as a number so it reads as itself.
var used = buttons.Sum(button => button.Bounds.Width);
used.ShouldBeLessThan(
LayoutHarness.VaultColumnWidth,
$"the selector needs {used:0} of {LayoutHarness.VaultColumnWidth:0} pixels; a fifth section "
+ "means shorter labels or a second row");
});
}
/// <summary>Lays the column out at the size the window gives it and hands the faults to an assertion.</summary>
private Task MeasureAsync(Action<IReadOnlyList<string>> assert) =>
OnTheColumnAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
/// <summary>Shows the column at the size the window gives it and runs one body against it.</summary>
private Task OnTheColumnAsync(Action<VaultColumn, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var column = new VaultColumn { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
column,
LayoutHarness.VaultColumnWidth,
LayoutHarness.VaultColumnHeight);
try
{
body(column, window);
}
finally
{
window.Close();
}
},
Token);
/// <remarks>
/// Enough rows in both lists that neither is empty, because an empty list is the easiest case and the one
/// least worth certifying — and since the selector arrived, the keys section has a whole column of its own
/// to fill rather than a capped strip at the bottom of the hosts one.
/// </remarks>
private async Task SeedAsync()
{
for (var i = 0; i < 6; i++)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = $"host-{i}";
vault.EditorHostname = $"host-{i}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
for (var i = 0; i < 4; i++)
{
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = $"key-{i}";
vault.KeyEditorPrivateKey =
$"-----BEGIN OPENSSH PRIVATE KEY-----\nMATERIAL-{i}\n-----END OPENSSH PRIVATE KEY-----\n";
await vault.SaveKeyCommand.ExecuteAsync(null);
}
for (var i = 0; i < 3; i++)
{
vault.NewCredentialCommand.Execute(null);
vault.CredentialEditorLabel = $"credential-{i}";
vault.CredentialEditorPassword = $"password-{i}";
vault.CredentialEditorUsername = $"account-{i}";
await vault.SaveCredentialCommand.ExecuteAsync(null);
}
// Pins come from approving a fingerprint at connect time, not from an editor, so they are seeded
// through the store the connect path writes to. Two for one endpoint, because a host offering keys
// of two algorithms is ordinary and the duplicate is one of the things this list has to show.
foreach (var (host, algorithm) in new[]
{
("host-0.internal", "ssh-ed25519"),
("host-0.internal", "ecdsa-sha2-nistp256"),
("gone.internal", "ssh-ed25519"),
})
{
await knownHosts.TrustAsync(
new HostKeyPresentation(
host, 22, algorithm, $"SHA256:{algorithm}-fingerprint-0123456789abcdefghijklmnop"),
Token);
}
// Back to where the column opens, so every test starts from the state a user would see.
vault.Section = VaultSection.Hosts;
await vault.LoadAsync(Token);
}
}