Files

332 lines
14 KiB
C#

using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// <see cref="VaultViewModel.HostSections"/>: the desktop board's own flattening, one section per group.
/// </summary>
/// <remarks>
/// <para>
/// Split out of <see cref="HostGridTests"/> rather than folded into it, because none of what is here needs a
/// realized <c>HostsScreen</c> — it is <see cref="VaultViewModel"/> alone, the same surface
/// <c>VaultViewModel.RebuildHostSections</c>, <c>FlattenIntoSections</c> and the monogram properties on
/// <see cref="HostRowViewModel"/> already carry doc comments for. This is where those comments are held to
/// account.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason every other suite in this
/// project uses one: compiled bindings resolve against the declared type, and a stand-in would still have to
/// be it.
/// </para>
/// </remarks>
public sealed class HostSectionsTests : 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($"host-sections-{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!;
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
knownHosts.Close();
await workspace.DisposeAsync();
await vault.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// The invariant <c>HostsScreen.axaml</c> depends on for a groupless keychain to look exactly as it did
/// before groups existed: one section, and its <c>Header</c> is null rather than a heading with nothing
/// worth saying. See <c>HostSectionViewModel.HasHeader</c> and the XAML's own <c>IsVisible</c> on it.
/// </remarks>
[Fact]
public async Task AGrouplessKeychainDrawsOneHeaderlessSection()
{
await AddHostAsync("prod-db");
await AddHostAsync("stage-web");
vault.HostSections.ShouldHaveSingleItem();
vault.HostSections[0].HasHeader.ShouldBeFalse();
vault.HostSections[0].Hosts.Select(row => row.Label)
.ShouldBe(["prod-db", "stage-web"], ignoreOrder: true);
}
/// <summary>
/// Every group is a section, in label order, "No group" first — the flattening the phone's
/// <c>SidebarRows</c> has always drawn, adopted by the desktop board in v5.
/// </summary>
/// <remarks>
/// The three groups are made out of label order on purpose — "zeta" before "alpha" before "mid" — so a
/// pass that happened to preserve creation order rather than sorting would still be caught.
/// </remarks>
[Fact]
public async Task SectionsAreInLabelOrderWithNoGroupFirst()
{
await AddGroupAsync("zeta");
await AddGroupAsync("alpha");
await AddGroupAsync("mid");
await AddHostAsync("loose");
await AddHostInGroupAsync("z-host", "zeta");
await AddHostInGroupAsync("a-host", "alpha");
await AddHostInGroupAsync("m-host", "mid");
vault.HostSections.Select(section => section.Header?.Label ?? "No group")
.ShouldBe(["No group", "alpha", "mid", "zeta"]);
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "alpha", StringComparison.Ordinal))
.Hosts.Select(row => row.Label).ShouldBe(["a-host"]);
}
/// <summary>
/// An empty group still gets a heading; a filter that empties a group's contents does not remove it, but
/// the ungrouped heading disappears entirely once nothing is left under it.
/// </summary>
[Fact]
public async Task AnEmptyNamedGroupKeepsItsHeadingAndAnEmptyUngroupedHeadingDropsAway()
{
await AddGroupAsync("empty-shelf");
await AddHostInGroupAsync("only-host", "empty-shelf");
vault.HostSections.Select(section => section.Header?.Label)
.ShouldNotContain("No group", "nothing is ungrouped, so that heading is not drawn at all");
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "empty-shelf", StringComparison.Ordinal))
.Hosts.Select(row => row.Label).ShouldBe(["only-host"]);
}
/// <remarks>
/// Collapsing a section keeps its heading — the count on it is still real — and empties its own card
/// list; expanding it again restores the cards. Both go through <c>ToggleGroupCommand</c>, the same
/// command the heading's chevron button is bound to.
/// </remarks>
[Fact]
public async Task TogglingASectionEmptiesItsCardsWithoutDroppingItsHeading()
{
await AddGroupAsync("production");
await AddHostInGroupAsync("prod-db", "production");
await AddHostInGroupAsync("prod-web", "production");
var header = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal)).Header!;
header.IsExpanded.ShouldBeTrue("nothing has folded it yet");
vault.ToggleGroupCommand.Execute(header);
var collapsed = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal));
collapsed.Header!.IsExpanded.ShouldBeFalse();
collapsed.Header.Count.ShouldBe(2, "the heading still says how many are under it");
collapsed.Hosts.ShouldBeEmpty("but the card list under it is folded away");
vault.ToggleGroupCommand.Execute(collapsed.Header);
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal))
.Hosts.Select(row => row.Label).ShouldBe(["prod-db", "prod-web"], ignoreOrder: true);
}
/// <remarks>
/// Collapse all folds every section at once and Expand all — the same command, its label swapped by
/// <c>CollapseAllLabel</c> — brings every one of them back. See the remark on that property for why it
/// reads "collapse" until every section already is one.
/// </remarks>
[Fact]
public async Task CollapseAllFoldsEverySectionAndExpandAllRestoresThem()
{
await AddGroupAsync("alpha");
await AddGroupAsync("beta");
await AddHostInGroupAsync("a-host", "alpha");
await AddHostInGroupAsync("b-host", "beta");
await AddHostAsync("loose");
vault.CollapseAllLabel.ShouldBe("Collapse all", "something is still open, so this is the fold action");
vault.ToggleAllGroupsCommand.Execute(null);
vault.HostSections.SelectMany(section => section.Hosts).ShouldBeEmpty("every section folded at once");
vault.CollapseAllLabel.ShouldBe("Expand all");
vault.ToggleAllGroupsCommand.Execute(null);
vault.HostSections.SelectMany(section => section.Hosts).Select(row => row.Label)
.ShouldBe(["loose", "a-host", "b-host"], ignoreOrder: true);
vault.CollapseAllLabel.ShouldBe("Collapse all");
}
/// <summary>
/// Group ▾, Tag ▾ and the find box all narrow the same board at once, and a host has to clear every one
/// of them that is active — the toolbar's two flyouts are an AND with each other and with the box, even
/// though a single flyout's own ticks are an OR among themselves.
/// </summary>
[Fact]
public async Task GroupFilterTagFilterAndFindComposeAsAnIntersection()
{
await AddGroupAsync("alpha");
await AddGroupAsync("beta");
await AddHostInGroupWithTagAsync("a-prod", "alpha", "prod");
await AddHostInGroupWithTagAsync("a-dev", "alpha", "dev");
await AddHostInGroupWithTagAsync("b-prod", "beta", "prod");
var alphaChoice = vault.GroupFilterChoices.Single(choice => choice.Label == "alpha");
vault.ToggleGroupFilterCommand.Execute(alphaChoice);
vault.HostBoardOrder.Select(row => row.Label)
.ShouldBe(["a-prod", "a-dev"], ignoreOrder: true, "narrowed to alpha, both its hosts");
var prodChoice = vault.TagFilterChoices.Single(choice => choice.Label == "prod");
vault.ToggleTagFilterCommand.Execute(prodChoice);
vault.HostBoardOrder.Select(row => row.Label)
.ShouldBe(["a-prod"], "alpha AND prod, not either alone — b-prod fails the group half");
vault.HostFilter = "a-dev";
vault.HostBoardOrder.ShouldBeEmpty(
"the find box adds a third condition none of alpha's prod-tagged hosts can pass");
vault.HostFilter = string.Empty;
vault.ResetTagFilterCommand.Execute(null);
vault.ResetGroupFilterCommand.Execute(null);
vault.HostBoardOrder.Select(row => row.Label)
.ShouldBe(["a-prod", "a-dev", "b-prod"], ignoreOrder: true, "every filter cleared");
}
/// <summary>
/// The same label always draws the same two letters and the same hue — across a rebuild, which replaces
/// every row object outright, and that is exactly the case worth holding: nothing on the row itself
/// survives a reload, so the answer has to come from the label alone.
/// </summary>
/// <remarks>
/// FNV-1a rather than <see cref="string.GetHashCode()"/> is the reason this can be asserted at all — see
/// <c>HostRowViewModel.MonogramHue</c>. A hash randomised per process would still pass a same-instance
/// comparison but never a same-label-after-reload one, which is the case that matters: a card must not
/// repaint itself a different colour because the application happened to restart.
/// </remarks>
[Fact]
public async Task TheSameLabelDrawsTheSameMonogramAcrossAReload()
{
await AddHostAsync("prod-db-01");
var before = Host("prod-db-01");
var monogram = before.Monogram;
var hue = before.MonogramHue;
monogram.ShouldBe("pd", "the first letters of the first two hyphen-separated words, lowercased");
await vault.LoadAsync(Token);
var after = Host("prod-db-01");
after.ShouldNotBeSameAs(before, "the reload replaces every row — this is the case worth holding");
after.Monogram.ShouldBe(monogram);
after.MonogramHue.ShouldBe(hue);
}
/// <remarks>
/// The one-word rule is a different branch of <c>HostRowViewModel.Monogram</c> than the two-word one
/// above, and worth its own case: it is the first two characters of the label rather than the first
/// letter of two words, which for a one-word host name are not the same computation by coincidence.
/// </remarks>
[Fact]
public async Task AOneWordLabelDrawsItsFirstTwoCharacters()
{
await AddHostAsync("Bastion");
Host("Bastion").Monogram.ShouldBe("ba");
}
// ---- Helpers ----
private HostRowViewModel Host(string label) =>
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
private async Task AddGroupAsync(string label)
{
vault.GroupEditorLabel = label;
await vault.SaveGroupCommand.ExecuteAsync(Token);
await vault.LoadAsync(Token);
}
private async Task AddHostAsync(string label)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(Token);
await vault.LoadAsync(Token);
}
private async Task AddHostInGroupAsync(string label, string groupLabel)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, groupLabel, StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(Token);
await vault.LoadAsync(Token);
}
private async Task AddHostInGroupWithTagAsync(string label, string groupLabel, string tag)
{
await AddHostInGroupAsync(label, groupLabel);
vault.SelectedHost = Host(label);
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewTag = tag;
await vault.AddEditorTagCommand.ExecuteAsync(Token);
await vault.SaveHostCommand.ExecuteAsync(Token);
await vault.LoadAsync(Token);
}
}