Public Access
Outstanding items #8 and #9, in one commit rather than two. They are separable as work and were built in that order, but not as a diff: the section enum has three members, the one-editor guard has three arms, and the picker offers keys and credentials from the same list. Reconstructing an #8-only state would mean hand-writing an intermediate version of VaultViewModel that never existed and that no test has ever run. One honest commit beats two invented ones. --- #8, the type selector --- The column showed two lists and two editors stacked in 340 pixels, and only just: the key list needed a MaxHeight and had to hide itself whenever its editor opened, both to stop the host list above it pushing the buttons off the bottom edge. Credentials would not have fitted at all. It now shows one kind at a time, chosen by a selector at the top, and both workarounds are gone because a section owns the whole column. Three departures from the plan, each with a reason found while building it. The selector is plain Buttons and a parameterised command, not a TabControl, a TabStrip or a ListBox. All three of those hold the selection themselves, so a click moves the highlight before the view model can refuse it — and this column does refuse, while an editor is open. A selector lit on a section the column is not showing is worse than the refusal it would be hiding. Buttons carry no state and cannot disagree with the vault. The one-editor-at-a-time rule survives with its justification replaced. That rule was a workaround for the sizing problem above, and sections dissolved it: the editors are in different sections and only one section is ever laid out. BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is now BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut — the same test, inverted, because its own comment said that if it ever started passing the rule had become unnecessary. It has. The rule stays for a better reason: an open key editor holds a pasted private key in a bound string, and letting the column move on would leave key material in a form nobody can see, with nothing on screen to say it is there. A sizing hack became a rule about not hiding a secret from the person holding it. KeyEditorIsInTheWay and HostEditorIsInTheWay are one AnEditorIsInTheWay, called by the section switch and by every editor-opening command. And releasing the keyboard from the terminal has never worked. MainWindow takes Win32 focus off the WebView's child window and then calls Focus() on VaultColumn.KeyboardTarget — and a ListBox is not focusable by default in Avalonia, which leaves focus to its items. So the call returned false, the window ended up with nothing focused, and the keystrokes went nowhere: exactly the state that method's own comment says its second half exists to prevent. Found by writing the test to assert focus was taken rather than that the right control was named — the cheap assertion was already passing. Fixed with Focusable="True" on every list. --- #9, credentials --- Credentials have synced since they were added and could not be created. They can now, and the sync layer needed no change at all: fourth item type, same result, which is the item-kind seam working as intended. One picker for all three ways a host authenticates, which is what makes the illegal combination unrepresentable rather than merely invalid. SshKeyChoice became AuthenticationChoice carrying an AuthenticationKind, and BuildHost reads both SshKeyId and CredentialId off that single selection, so a host naming a key and a credential — which HostSecret.TryValidate refuses — cannot be expressed. Two pickers would have expressed it and then rejected it at save time. The kind travels with the id in three places and none is padding: Missing takes it, the placeholder lookup matches on kind as well as id, and Bound(kind) returns null unless the selection is that kind. Drop any one and a dangling credential comes back as a dangling key, which saves as a key binding to an id no key has. A credential's username had to reach the SSH request, not just its password. TryBuildCredential returned only the secret and the connect path read the username off the host, so a stored credential would have gone out under the wrong account — wrong in a way a server only reports as "authentication failed". It is now TryBuildAuthentication returning a (Username, Credential) pair. The no-username refusal moved, and had to. It ran before anything looked at the binding, which made a credential's username unreachable in the one case it is most useful: a host somebody never filled a username in for. It is now the last thing every branch agrees on, so such a host is perfectly usable through a credential that carries one, and a host with neither still refuses and now says where to put one. --- What the measurements cost --- Ten mutations, all caught. Two are worth naming. Removing a section's IsVisible is caught by OnlyOneSectionIsOnScreenAtOnce and by nothing else: two visible sections overlap in the row they share rather than clip, so every fit test still passes while the column shows one list through another. Defaulting the credential selection to the first row is caught by ReloadingKeepsACredentialSelectionButNeverInventsOne, and the property is a safety one rather than tidiness — Delete acts on the selection, so a list that picked a row on every background sync would aim a one-click password deletion at something nobody chose. The key list has the same property, and its comment cited a method that has not existed for some time; both now name the delete command they actually protect. One test of mine could not fail, and the mutation pass is what found it. AHostBoundToACredential_SendsItsPasswordAndItsUsername gave the credential and the host the same username, so it passed whichever one the code read. An override is only tested when the two values differ. Two shipped statements went false and were corrected rather than left: the class remark saying passwords were "not yet" in the vault, and the terminal column's "Keys are in the vault; passwords are not yet." That column's hint is now a tooltip on the password box rather than a sentence in the row, which was measured the hard way — by looking. At the window's 820px minimum the column gets 480, and a 220px box plus Connect plus any sentence does not fit; the row has shipped clipped for as long as it has had a hint in it. That strip is the one part of the window nothing can measure, because MainWindow cannot be laid out headlessly at all. Extracting it into its own control, as the vault column was extracted for exactly this reason, is what would fix that, and is not done here. 911 tests green, 30 of them new. Zero warnings, dotnet format clean. Seen by a person, which is how the two defects above were found. Still open from that pass: unlocking with the device key raises its consent dialog and then never returns, while registering one works — the difference is which thread the CNG call lands on, and diagnosing it properly is its own change.
384 lines
16 KiB
C#
384 lines
16 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 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");
|
|
});
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <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,
|
|
};
|
|
|
|
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(3, "one per section that exists");
|
|
|
|
foreach (var button in buttons)
|
|
{
|
|
button.Bounds.Height.ShouldBeGreaterThan(20);
|
|
button.Bounds.Width.ShouldBeGreaterThan(40);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|