using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.VisualTree; using DodoSSH.Client.App.Views; 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; /// /// How the host list answers a pointer. /// /// /// /// Separate from , which measures this control rather than driving it. What /// is here is the one gesture that cannot be expressed as a binding and cannot be checked by measuring: a /// right click has to move the selection before the menu opens, because all three of that menu's /// commands read the vault's host selection. A menu that quietly acted on whichever host happened to be /// selected would delete the wrong machine, which is the version of this mistake worth a suite. /// /// /// A real over a real unlocked vault, for the reason the other suites here use /// one: compiled bindings resolve against the declared type, and the list is built out of the vault's own /// hosts and groups. /// /// public sealed class HostSidebarTests : 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($"sidebar-{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); await SeedAsync(); } /// public async ValueTask DisposeAsync() { knownHosts.Close(); await workspace.DisposeAsync(); await vault.DisposeAsync(); caches.Dispose(); } /// /// The rule the menu depends on. Without it the three commands would act on whatever was selected /// before, which for Delete is a question asked about one machine and answered about another. /// [Fact] public async Task ARightClickSelectsTheHostUnderThePointer() { await OnTheSidebarAsync((sidebar, window) => { var first = Row(vault, "prod-db"); var other = Row(vault, "stage-web"); vault.SelectedHost = first; RightClick(RowFor(sidebar, other), window); vault.SelectedHost.ShouldBeSameAs(other); var menu = sidebar.HostList.ContextMenu.ShouldNotBeNull(); menu.IsOpen.ShouldBeTrue(); // The commands are the vault's, which is the other half of putting the menu on the list rather // than in the item template: a menu inside the template would have the row for its data context, // and every one of these would silently bind to nothing. var edit = menu.Items.OfType().Single(item => item.Header is "Edit…"); edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand); edit.Command!.Execute(null); vault.IsEditing.ShouldBeTrue(); vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before"); }); } /// /// A heading is a row in the same list and the control will happily select it, but it is not a host — /// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing /// or act on a machine somewhere else in the list. /// [Fact] public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing() { await OnTheSidebarAsync((sidebar, window) => { var selected = Row(vault, "prod-db"); vault.SelectedHost = selected; var heading = sidebar.HostList .GetVisualDescendants() .OfType() .First(item => item.DataContext is SidebarGroupHeader); RightClick(heading, window); vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on"); sidebar.HostList.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse(); }); } // ---- Helpers ---- private static void RightClick(Visual row, Visual window) { var at = Centre(row, window); ((Window)window).MouseDown(at, MouseButton.Right); ((Window)window).MouseUp(at, MouseButton.Right); } private Task OnTheSidebarAsync(Action body) => LayoutHarness.OnTheUiThreadAsync( () => { var sidebar = new HostSidebar { DataContext = vault }; var window = LayoutHarness.HostAtMinimumSize( sidebar, LayoutHarness.HostSidebarWidth, LayoutHarness.ScreenHeight); try { body(sidebar, window); } finally { window.Close(); } }, Token); private static ListBoxItem RowFor(Visual sidebar, HostRowViewModel host) => sidebar.GetVisualDescendants() .OfType() .First(item => ReferenceEquals(item.DataContext, host)); private static HostRowViewModel Row(VaultViewModel vault, string label) => vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal)); private static Point Centre(Visual control, Visual window) => control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window) ?? throw new InvalidOperationException("the control is not in this window's tree"); /// Two hosts and a group, so there is a heading in the list and a selection to move off. private async Task SeedAsync() { foreach (var label in new[] { "prod-db", "stage-web" }) { vault.NewHostCommand.Execute(null); vault.EditorLabel = label; vault.EditorHostname = $"{label}.internal"; vault.EditorUsername = "deploy"; await vault.SaveHostCommand.ExecuteAsync(null); } vault.GroupEditorLabel = "production"; await vault.SaveGroupCommand.ExecuteAsync(null); await vault.LoadAsync(Token); } }