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; /// /// : the desktop board's own flattening, one section per group. /// /// /// /// Split out of rather than folded into it, because none of what is here needs a /// realized HostsScreen — it is alone, the same surface /// VaultViewModel.RebuildHostSections, FlattenIntoSections and the monogram properties on /// already carry doc comments for. This is where those comments are held to /// account. /// /// /// A real 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. /// /// public sealed class HostSectionsTests : IAsyncLifetime { private const string Passphrase = "a sufficiently long passphrase"; private const string ServerUrl = "https://dodossh.example"; /// Far below the shipped profile: nothing here attacks a wrap. 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; /// 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(StringComparer.Ordinal)), Substitute.For(), TimeProvider.System); await knownHosts.OpenAsync(session, Token); vault = new VaultViewModel(session, workspace, knownHosts, static () => null); } /// public async ValueTask DisposeAsync() { knownHosts.Close(); await workspace.DisposeAsync(); await vault.DisposeAsync(); caches.Dispose(); } /// /// The invariant HostsScreen.axaml depends on for a groupless keychain to look exactly as it did /// before groups existed: one section, and its Header is null rather than a heading with nothing /// worth saying. See HostSectionViewModel.HasHeader and the XAML's own IsVisible on it. /// [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); } /// /// Every group is a section, in label order, "No group" first — the flattening the phone's /// SidebarRows has always drawn, adopted by the desktop board in v5. /// /// /// 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. /// [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"]); } /// /// 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. /// [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"]); } /// /// 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 ToggleGroupCommand, the same /// command the heading's chevron button is bound to. /// [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); } /// /// Collapse all folds every section at once and Expand all — the same command, its label swapped by /// CollapseAllLabel — brings every one of them back. See the remark on that property for why it /// reads "collapse" until every section already is one. /// [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"); } /// /// 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. /// [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"); } /// /// 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. /// /// /// FNV-1a rather than is the reason this can be asserted at all — see /// HostRowViewModel.MonogramHue. 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. /// [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); } /// /// The one-word rule is a different branch of HostRowViewModel.Monogram 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. /// [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); } }