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; /// /// Whether the vault column fits in the space the window gives it. /// /// /// /// The column is 340 pixels wide and holds a list and an editor per item type, and the only thing keeping it /// from clipping its own Save button at the window's minimum height is a state rule — one editor open at a /// time. That rule was added on the strength of an argument, not a measurement, and this suite is the /// measurement. /// /// /// A real VaultViewModel 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. /// /// public sealed class VaultColumnLayoutTests : 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($"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(StringComparer.Ordinal)), Substitute.For(), 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(); } /// public async ValueTask DisposeAsync() { await vault.DisposeAsync(); knownHosts.Close(); await workspace.DisposeAsync(); await session.DisposeAsync(); caches.Dispose(); } [Fact] public async Task TheColumnFitsWithNoEditorOpen() { await MeasureAsync(faults => faults.ShouldBeEmpty()); } [Fact] public async Task TheColumnFitsWithTheHostEditorOpen() { vault.NewHostCommand.Execute(null); vault.IsEditing.ShouldBeTrue(); await MeasureAsync(faults => faults.ShouldBeEmpty()); } [Fact] public async Task TheColumnFitsWithTheKeyEditorOpen() { // The tall one: a private key needs a real text area, and this is the editor the MaxHeight on the key // list exists to make room for. vault.NewKeyCommand.Execute(null); vault.IsEditingKey.ShouldBeTrue(); vault.KeyEditorPrivateKey = string.Join( '\n', Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6)); await MeasureAsync(faults => faults.ShouldBeEmpty()); } [Fact] public async Task BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists() { // The justification for KeyEditorIsInTheWay/HostEditorIsInTheWay, turned from an argument in a comment // into a number. The flags are set directly because the commands refuse this on purpose — the point is // to measure what the refusal is protecting. // // If this test ever starts passing, the rule has become unnecessary and the comments claiming it is // load-bearing have become false. That is a finding, not a flake: read it as "the column has room // now", check what changed, and delete the rule rather than this test. vault.IsEditing = true; vault.IsEditingKey = true; await MeasureAsync(faults => faults.ShouldNotBeEmpty( "one editor at a time is a workaround for a column that cannot hold two")); } /// Lays the column out at the size the window gives it and hands the faults to an assertion. private Task MeasureAsync(Action> assert) => LayoutHarness.OnTheUiThreadAsync( () => { var window = LayoutHarness.HostAtMinimumSize( new VaultColumn { DataContext = vault }, LayoutHarness.VaultColumnWidth, LayoutHarness.VaultColumnHeight); try { assert(LayoutHarness.Unreachable(window)); } finally { window.Close(); } }, Token); /// /// Enough rows that the lists are not empty, because an empty list is the easiest case and the one least /// worth certifying. /// 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); } await vault.LoadAsync(Token); } }