using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.App.Tests;

/// <summary>
/// Vaults, from the side that holds the keys: make one, add somebody, and wrap its key to them.
/// </summary>
/// <remarks>
/// <para>
/// The reason this suite exists rather than leaving sharing to the server's own tests is that the
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
/// lookup with a key of their own.
/// </para>
/// <para>
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
/// sharing works, not that verification does.
/// </para>
/// <para>
/// It was <c>TeamSharingTests</c>, and the screen it drives stopped being about teams: a vault is what
/// gets made and named, and the membership list behind it is made with it. The team is still what the
/// server authorises against, which is why the assertions about roles and hand-over are all still here —
/// they are the same operations, reached through the vault they apply to.
/// </para>
/// </remarks>
public sealed class VaultSharingTests : IAsyncLifetime
{
    private const string Passphrase = "a sufficiently long passphrase";

    private static readonly Argon2Profile CheapProfile =
        Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);

    private readonly FakeVaultServer server = new();
    private readonly FakeSshConnectionFactory ssh = new();

    private string directory = null!;
    private ClientCacheFactory caches = null!;
    private TerminalWorkspace workspace = null!;
    private VaultKnownHostStore knownHosts = null!;
    private FakeDeviceKeyStore deviceKeys = null!;
    private MainWindowViewModel shell = null!;

    private static CancellationToken Token => TestContext.Current.CancellationToken;

    /// <inheritdoc />
    public ValueTask InitializeAsync()
    {
        directory = Path.Combine(Path.GetTempPath(), $"dodossh-vaults-{Guid.CreateVersion7():N}");

        var paths = new ClientPaths(directory);

        caches = ClientCacheFactory.ForFile(paths.CacheFile);
        knownHosts = new VaultKnownHostStore();
        deviceKeys = new FakeDeviceKeyStore();

        workspace = new TerminalWorkspace(
            new InMemoryTerminalAssetProvider(
                new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
            ssh,
            TimeProvider.System);

        shell = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            knownHosts,
            deviceKeys,
            (_, _) => Task.FromResult<IVaultServer>(server),
            TimeProvider.System,
            NSubstitute.Substitute.For<ISftpSessionFactory>(),
            CheapProfile);

        return ValueTask.CompletedTask;
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        await shell.DisposeAsync();
        knownHosts.Close();
        await workspace.DisposeAsync();
        caches.Dispose();

        try
        {
            Directory.Delete(directory, recursive: true);
        }
        catch (IOException)
        {
            // A cache file the process has not finished releasing. The directory is under the temp path
            // and named per run, so leaving it costs a few kilobytes and never collides.
        }
    }

    /// <remarks>
    /// The whole point of a shared vault, in one test. Adding somebody wraps the vault to them, so the
    /// status line names what they were given rather than what is still owed — and the grant is on the
    /// server before the add has finished reporting.
    /// </remarks>
    [Fact]
    public async Task AddingSomebody_WrapsTheVaultToThemStraightAway()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddAccount("bob@example.com", "Bob Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        var vaultId = vaults.SelectedVault!.VaultId;

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.Members.Count.ShouldBe(2, vaults.Status);

        server.IssuedGrants.ShouldContainKey(
            (vaultId, colleague),
            "adding somebody to a vault is what shares it with them");

        vaults.Status.ShouldContain("Platform secrets");
    }

    /// <remarks>
    /// The manual path still works and is still worth having: a vault whose key this machine did not
    /// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
    /// somebody who already holds the key is the same call, and the server replaces the row rather than
    /// adding a second one.
    /// </remarks>
    [Fact]
    public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddAccount("bob@example.com", "Bob Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);

        await vaults.ShareVaultCommand.ExecuteAsync(null);

        var vaultId = vaults.SelectedVault!.VaultId;

        server.IssuedGrants.ShouldContainKey((vaultId, colleague));
        vaults.Status.ShouldContain("Shared");

        // The one thing verification cannot promise, said in the same breath as the success.
        vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
    }

    /// <remarks>
    /// <para>
    /// The other half of the same idea. Removing somebody withdraws their grants — which only blocks
    /// future reads — so the vault is rotated in the same breath and the new key goes to the people who
    /// are left. From that moment nothing written is readable to the person who went.
    /// </para>
    /// <para>
    /// The remaining member is given the earlier generation as well as the new one, which is what keeps
    /// the vault's existing items readable to them: a rotation re-keys the vault, not its contents.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task RemovingSomebody_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var leaving = server.AddAccount("bob@example.com", "Bob Example");
        var staying = server.AddAccount("carol@example.com", "Carol Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        var vaultId = vaults.SelectedVault!.VaultId;

        foreach (var address in (string[])["bob@example.com", "carol@example.com"])
        {
            vaults.NewMemberEmail = address;
            await vaults.AddMemberCommand.ExecuteAsync(null);
        }

        vaults.Members.Count.ShouldBe(3, vaults.Status);

        vaults.SelectedMember = vaults.Members.Single(member => member.UserId == leaving);

        await vaults.RemoveMemberCommand.ExecuteAsync(null);

        vaults.Status.ShouldContain("Rotated", customMessage: vaults.Status);
        vaults.Status.ShouldContain("Platform secrets");

        // The last act of a rotation is moving what is already stored onto the new key. Proven by the
        // bytes in DodoSSH.Client.Sync.Tests; what this asserts is that the shell asks for it at all,
        // and says which of the two guarantees the user has ended up with.
        vaults.Status.ShouldContain("re-sealed under the new key", customMessage: vaults.Status);

        // Gone entirely, at every generation. A revocation that left the history behind would leave them
        // able to read everything written before they went, from a copy of the ciphertext.
        server.GenerationsGranted(vaultId, leaving).ShouldBeEmpty();

        // And the member who stayed holds both: the new key for what comes next, the old one for what
        // is already stored under it.
        server.GenerationsGranted(vaultId, staying).ShouldBe([1u, 2u]);
    }

    /// <remarks>
    /// Somebody added after a rotation is given every generation the sharing machine holds, not only the
    /// newest. A vault shared as one key would open to a list of items that will not decrypt, which
    /// reads as corruption rather than as the missing grant it is.
    /// </remarks>
    [Fact]
    public async Task AddingSomebodyToARotatedVault_HandsThemItsHistoryAsWell()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var first = server.AddAccount("bob@example.com", "Bob Example");
        var second = server.AddAccount("carol@example.com", "Carol Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        var vaultId = vaults.SelectedVault!.VaultId;

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        // Removing them is what rotates the vault, so the next person to be added arrives at a vault
        // with a history rather than one that has only ever had a single key.
        vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
        await vaults.RemoveMemberCommand.ExecuteAsync(null);

        vaults.NewMemberEmail = "carol@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
    }

    /// <remarks>
    /// <para>
    /// The test this whole design exists for. A server that wants to read a shared vault only has to
    /// answer one directory lookup with a key it holds the private half of — so the client reads the
    /// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
    /// offered is in there unchanged.
    /// </para>
    /// <para>
    /// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
    /// worse than no check at all, because the interface would have said it was verified.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddAccount("mallory@example.com", "Mallory Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        // Before the add, because the add now shares. Both routes to a wrap have to refuse, and a test
        // that corrupted the log afterwards would be asserting about the second one only.
        server.CorruptKeyLog = true;

        vaults.NewMemberEmail = "mallory@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        var vaultId = vaults.SelectedVault!.VaultId;

        server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
        vaults.Status.ShouldContain("Could not share");
        vaults.Status.ShouldContain("key log");

        vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);

        await vaults.ShareVaultCommand.ExecuteAsync(null);

        server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
        vaults.Status.ShouldContain("Did not share");
        vaults.Status.ShouldContain("key log");
    }

    /// <remarks>
    /// A vault created here is usable here, without a relock. The key was generated in this process, so
    /// making the user lock and unlock to reach the vault they just made would be asking them to work
    /// around bookkeeping.
    /// </remarks>
    [Fact]
    public async Task AVaultCreatedHere_IsImmediatelyReadableAndWritable()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vaultId = vaults.SelectedVault!.VaultId;
        var session = shell.Vault!.Session;

        session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);

        // And it is offered as somewhere to file a new item, which is what makes it worth having.
        await shell.Vault.LoadAsync(Token);

        shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
        shell.Vault.HasVaultChoice.ShouldBeTrue();
    }

    /// <remarks>
    /// <para>
    /// And it reaches a host editor that was already open, which is very nearly the only way a vault gets made:
    /// somebody starts adding a host, realises it belongs to the team rather than to them, and goes to make
    /// somewhere to put it. The editor's picker is its own — see <c>VaultViewModel.EditorVaultChoices</c> — and
    /// it was filled when the editor opened, so the vault they had just made for this host was the one place
    /// they could not file it without throwing the form away.
    /// </para>
    /// <para>
    /// The second half is what stops the fix being worse than the bug: the refill adds the entry and leaves the
    /// selection alone, so a vault appearing cannot move a half-typed host into it.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task AVaultCreatedWithAHostEditorOpen_IsOfferedAsSomewhereToFileThatHost()
    {
        await UnlockedAsync();

        var vault = shell.Vault!;

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";

        await CreateVaultAsync(shell.Vaults, "Platform secrets");

        var vaultId = shell.Vaults.SelectedVault!.VaultId;

        vault.IsEditing.ShouldBeTrue("making a vault must not close the host editor");
        vault.EditorLabel.ShouldBe("prod-db", "nor discard what has been typed into it");

        vault.ShowsEditorVaultChoice.ShouldBeTrue(
            "a second writable vault is what makes the picker worth drawing at all");

        vault.EditorVaultChoices.Select(choice => choice.VaultId).ShouldContain(vaultId);

        vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal
            .ShouldBeTrue("the refill must not move the host into the vault that has just appeared");

        // Choosing it, on the other hand, files the host there — which is what the picker was for.
        vault.EditorSelectedVault = vault.EditorVaultChoices.Single(
            choice => choice.VaultId == vaultId);

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().VaultId.ShouldBe(vaultId, vault.Status);
    }

    /// <remarks>
    /// The snippet editor's picker is a copy of the same list — see
    /// <c>SnippetsViewModel.BuildEditorVaultChoices</c> — and went stale in exactly the same way. It watches the
    /// list rather than the reload, because this screen has always been a wrapper over the vault's collections
    /// and has no reload of its own to hang off.
    /// </remarks>
    [Fact]
    public async Task AVaultCreatedWithASnippetEditorOpen_IsOfferedAsSomewhereToFileThatSnippet()
    {
        await UnlockedAsync();

        var vault = shell.Vault!;
        var snippets = SnippetsOver(vault);

        snippets.NewCommand.Execute(null);
        snippets.EditorLabel = "restart the api";
        snippets.EditorCommand = "sudo systemctl restart dodossh-api";

        await CreateVaultAsync(shell.Vaults, "Platform secrets");

        var vaultId = shell.Vaults.SelectedVault!.VaultId;

        snippets.IsEditing.ShouldBeTrue("making a vault must not close the snippet editor");
        snippets.EditorCommand.ShouldBe(
            "sudo systemctl restart dodossh-api",
            "nor discard what has been typed into it");

        snippets.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between now");
        snippets.EditorVaultChoices.Select(choice => choice.VaultId).ShouldContain(vaultId);

        snippets.EditorSelectedVault.ShouldNotBeNull().IsPersonal
            .ShouldBeTrue("the refill must not move the snippet into the vault that has just appeared");

        snippets.EditorSelectedVault = snippets.EditorVaultChoices.Single(
            choice => choice.VaultId == vaultId);

        await snippets.SaveCommand.ExecuteAsync(null);

        Snippet(snippets, "restart the api").VaultId.ShouldBe(vaultId, snippets.Status);
    }

    /// <remarks>
    /// Making a vault makes exactly one membership list, and this is the assertion that the two-step create
    /// has not started leaking them: the screen no longer offers to make one on its own, so a second one
    /// per vault would be invisible in the interface and visible only to an operator.
    /// </remarks>
    [Fact]
    public async Task CreatingAVault_MakesOneMembershipListWithTheCallerAsItsOwner()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        server.TeamCreates.ShouldBe(1);

        var row = vaults.Vaults.Single(
            vault => string.Equals(vault.Name, "Platform secrets", StringComparison.Ordinal));

        row.IsShared.ShouldBeTrue("a vault made here is one other people can be added to");
        row.IsOwned.ShouldBeTrue(vaults.Status);
        row.SharedWithOtherVaults.ShouldBe(0, "it was made with a membership list of its own");

        vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
    }

    /// <remarks>
    /// <para>
    /// Sharing from the receiving end, which is the half that has to happen on somebody else's machine and
    /// the half that was missing. A vault wrapped to this account appears in <c>/me</c> and nowhere else —
    /// there is no push channel — so a client that never re-read that list showed nothing, indefinitely,
    /// while the server and the grant were both perfectly correct.
    /// </para>
    /// <para>
    /// Readable rather than merely listed, because those are two different failures with the same symptom:
    /// a row that cannot be opened is a vault whose key never arrived, and this asserts the wrap was taken
    /// into the keyring. The switch is asserted too — it is built by the shell rather than by the vault, so
    /// it is the one thing a pass could admit a vault without redrawing.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task AVaultSomebodyElseShared_ArrivesOnTheNextSynchronisation()
    {
        await UnlockedAsync();

        var colleague = server.AddAccount("bob@example.com", "Bob Example");
        var vaultId = server.ShareVaultWithMe("Platform secrets", colleague);

        var vault = shell.Vault.ShouldNotBeNull();

        await vault.SyncCommand.ExecuteAsync(null);

        vault.Session.ReadableVaults.ShouldContain(
            row => row.VaultId == vaultId,
            "a vault shared with this account arrives on a synchronisation pass, with its key");

        shell.VaultToggles.ShouldContain(
            toggle => toggle.VaultId == vaultId,
            "the tab strip's vault menu is built by the shell and has to be told");

        await shell.Vaults.LoadAsync(Token);

        var row = shell.Vaults.Vaults.Single(vault => vault.VaultId == vaultId);

        row.IsReadable.ShouldBeTrue(shell.Vaults.Status);
        row.IsOwned.ShouldBeFalse("somebody else made this one");
    }

    /// <remarks>
    /// <para>
    /// Deleting a shared vault, which is an admin's operation and the only one on this screen that cannot
    /// be undone. It has to take three things with it: the vault, everybody's key to it — including the
    /// people it was shared with — and this machine's own copy of the row, so the list is right before the
    /// next refresh rather than after it.
    /// </para>
    /// <para>
    /// The status line is asserted for what it says about the limit rather than for its wording. A message
    /// implying that deletion reaches a colleague's laptop would be the one dishonest sentence this screen
    /// could print; see ADR 0001.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task DeletingAVault_TakesItAndEverybodysKeyToIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddAccount("bob@example.com", "Bob Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        var vaultId = vaults.SelectedVault!.VaultId;

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        server.IssuedGrants.ShouldContainKey((vaultId, colleague));

        vaults.CanDeleteSelected.ShouldBeTrue("an admin may delete a shared vault");

        vaults.DeleteVaultCommand.Execute(null);

        var question = vaults.PendingAction.ShouldNotBeNull("deletion is never carried out unasked");

        question.Consequence.ShouldContain(
            "already synced", Case.Insensitive, "the one limit this must not leave implied");

        await vaults.ConfirmActionCommand.ExecuteAsync(null);

        vaults.Vaults.ShouldNotContain(row => row.VaultId == vaultId, vaults.Status);
        server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));

        shell.Vault!.Session.Vaults.ShouldNotContain(
            row => row.VaultId == vaultId,
            "the machine that deleted it does not wait for a refresh to stop listing it");

        shell.VaultToggles.ShouldNotContain(toggle => toggle.VaultId == vaultId);
    }

    /// <remarks>
    /// The one vault deletion cannot reach, refused by the screen rather than by the server: everything
    /// filed nowhere else lives in it and nothing can make another, so the button is not offered and the
    /// command says why if something reaches it anyway.
    /// </remarks>
    [Fact]
    public async Task ThePersonalVault_CannotBeDeleted()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);

        vaults.SelectedVault = vaults.Vaults.Single(vault => vault.IsPersonal);

        vaults.CanDeleteSelected.ShouldBeFalse();

        vaults.DeleteVaultCommand.Execute(null);

        vaults.PendingAction.ShouldBeNull("nothing was armed");
        vaults.Status.ShouldContain("cannot be deleted");
        vaults.Vaults.ShouldContain(vault => vault.IsPersonal);
    }

    /// <remarks>
    /// The personal vault is in the list, is marked as the one thing it is, and offers nothing to share:
    /// the server refuses a grant on one outright, so a screen that let somebody try would be sending them
    /// at a refusal.
    /// </remarks>
    [Fact]
    public async Task ThePersonalVault_IsListedAndCannotBeSharedWithAnybody()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);

        var personal = vaults.Vaults.ShouldHaveSingleItem();

        personal.IsPersonal.ShouldBeTrue();
        personal.IsShared.ShouldBeFalse();
        personal.RoleLabel.ShouldBe("PERSONAL");

        vaults.SelectedVault = personal;
        vaults.SelectedIsShared.ShouldBeFalse();

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.Members.ShouldBeEmpty();
        vaults.Status.ShouldContain("cannot be shared");
    }

    /// <remarks>
    /// Filing into a shared vault has to be chosen and has to stick. The bug this guards is the obvious
    /// one: an editor that read the picker at save time rather than at open time, so changing the picker
    /// with a half-typed host on screen would move it.
    /// </remarks>
    [Fact]
    public async Task AHostFiledIntoASharedVault_StaysThere()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        vault.SelectedTargetVault =
            vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorUsername = "deploy";

        // Moved back after the editor opened. The host must still land in the shared vault: the keychain
        // screen's picker seeds the editor's and stops mattering from there.
        vault.SelectedTargetVault =
            vault.TargetVaults.First(choice => choice.VaultId != sharedVaultId);

        await vault.SaveHostCommand.ExecuteAsync(null);

        var row = vault.Hosts.Single(
            host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
        row.VaultId.ShouldBe(sharedVaultId);
    }

    /// <remarks>
    /// <para>
    /// Moving a host into a shared vault, which is the operation that used to require deleting it and
    /// typing it again: the two vaults are encrypted under different keys, so what happens underneath is a
    /// re-seal into one and a tombstone in the other. The host has to arrive intact, be gone from where it
    /// was, and carry a new id — one entity id in two vaults would make the destination's row and the
    /// source's tombstone the same row.
    /// </para>
    /// <para>
    /// The group is asserted cleared, and that is the half worth a test rather than a comment. A group is
    /// an item of the vault the host is leaving, so a host that carried the reference across would resolve
    /// it on this machine — groups are resolved over every readable vault — and dangle for everybody else
    /// in the destination. The mover and their colleagues would be looking at two different hosts.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task MovingAHostToAnotherVault_ReSealsItThereAndLeavesItsGroupBehind()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        // In the personal vault, under a group of its own, which is what the move has to leave behind.
        vault.NewGroupCommand.Execute(null);
        vault.GroupEditorLabel = "Production";
        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorUsername = "deploy";
        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));

        await vault.SaveHostCommand.ExecuteAsync(null);

        var before = vault.Hosts.Single(
            host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));

        before.VaultId.ShouldNotBe(sharedVaultId);
        before.Host.GroupId.ShouldNotBeNull("the host was filed under a group before the move");

        vault.SelectedHost = before;
        vault.CanMoveSelectedHost.ShouldBeTrue("there is a second vault this session can write to");

        vault.MoveHostCommand.Execute(null);

        vault.IsMovingHost.ShouldBeTrue(vault.Status);
        vault.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);

        vault.SelectedMoveVault =
            vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        // The pass that follows every write on this screen is made to fail, so that the move's own sentence
        // is still on the status line to be read. That is not a contrivance to dodge a race: a successful
        // pass reports what it moved and supersedes the confirmation of every save, delete and move alike —
        // pre-existing behaviour of the whole screen — and the state asserted here is the one where the
        // sentence matters most, because nothing has reached the server yet.
        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmMoveHostCommand.ExecuteAsync(null);

        var after = vault.Hosts.Single(
            host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));

        after.VaultId.ShouldBe(sharedVaultId, vault.Status);
        after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
        after.Host.Hostname.ShouldBe("db.internal");
        after.Host.Username.ShouldBe("deploy");
        after.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");

        vault.SelectedHost?.EntityId.ShouldBe(after.EntityId, "the pane follows the host it moved");
        vault.Status.ShouldContain("Platform secrets");
        vault.Status.ShouldContain("group", Case.Insensitive);
    }

    /// <remarks>
    /// <para>
    /// ◆ <b>The phone's action bar sending a whole selection across, in one run.</b> The single-host move
    /// above is the desktop's; this is the same write over a set, and the thing worth pinning is that it
    /// obeys the same rule — the group and the tags are items of the vault being left, so nothing carries
    /// them across.
    /// </para>
    /// <para>
    /// A host already in the destination is skipped rather than refusing the whole run, and the sentence
    /// afterwards says how many were left alone. Eleven machines with one that had nowhere to go must not do
    /// nothing at all and then report about the wrong ten.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task MovingTheChosenHostsToAnotherVault_TakesThemAllAndSkipsTheOnesAlreadyThere()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        await SeedForTheChosenHostMoveAsync(vault, sharedVaultId);

        foreach (var row in vault.Hosts.ToList())
        {
            vault.ChooseHostCommand.Execute(row);
        }

        vault.ChosenHostCount.ShouldBe(2);
        vault.MoveChosenHostsToVaultCommand.Execute(null);

        vault.IsSendingChosenHostsToAVault.ShouldBeTrue(vault.Status);
        vault.ChosenHostsAreBeingCopied.ShouldBeFalse();

        // Every writable vault, because the selection spans two of them — there is no single vault to leave
        // out, and shrinking the list to the intersection would offer nothing at all.
        vault.SelectedChosenHostVault =
            vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        // As the single-host move's own test does, and for the reason written there: a successful pass
        // reports what it pushed and supersedes the run's own sentence, which is what is being read here.
        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);

        vault.Hosts.Count.ShouldBe(2, "nothing was duplicated on the way across");
        vault.Hosts.ShouldAllBe(row => row.VaultId == sharedVaultId);
        vault.Hosts
            .Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
            .Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");

        vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
        vault.Status.ShouldContain("Platform secrets");
        vault.Status.ShouldContain("left alone", Case.Insensitive);
    }

    /// <remarks>
    /// <para>
    /// The palette reaches every vault the session holds a key for, so the vault's name is one of the things
    /// somebody can reasonably type into it: "show me the team's machines" is a question a list spanning two
    /// vaults invites, and until now the only answer was to know one of the machines by name already.
    /// </para>
    /// <para>
    /// The order is the assertion that matters. A vault name is the widest of the three readings — one word
    /// matches every host in that vault at once — so the host whose own name carries the word has to come
    /// first, or typing a machine's name would bury it under everybody else's.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ThePalette_FindsAHostByItsVaultsName_BehindOneCalledThatItself()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "platform-gateway";
        vault.EditorHostname = "gateway.internal";

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.NewHostCommand.Execute(null);
        vault.EditorSelectedVault =
            vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.IsEditing.ShouldBeFalse(vault.Status);

        shell.ToggleSearchCommand.Execute(null);
        shell.SearchText = "platform";

        shell.SearchResults
            .Select(row => row.Label)
            .ShouldBe(["platform-gateway", "prod-db"], "a host's own name outranks its vault's");

        shell.SearchResults[1].VaultBadge.ShouldBe(
            "PLATFORM SECRETS", "and the row says what it was found by");
    }

    /// <summary>
    /// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
    /// </summary>
    /// <remarks>
    /// The first is what the move has to strip a group off on the way across; the second is the row that has
    /// to be skipped rather than turned into a second copy of itself.
    /// </remarks>
    private static async Task SeedForTheChosenHostMoveAsync(VaultViewModel vault, Guid sharedVaultId)
    {
        vault.NewGroupCommand.Execute(null);
        vault.GroupEditorLabel = "Production";
        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.NewHostCommand.Execute(null);
        vault.EditorSelectedVault =
            vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
        vault.EditorLabel = "prod-web";
        vault.EditorHostname = "web.internal";

        await vault.SaveHostCommand.ExecuteAsync(null);
    }

    /// <remarks>
    /// The other verb behind the same picker. What makes it worth its own test is the half that is not a
    /// move: the original stays where it is, so a host shared with a team is still readable by the person
    /// who shared it.
    /// </remarks>
    [Fact]
    public async Task CopyingAChosenHostToAnotherVault_LeavesTheOriginalWhereItIs()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorUsername = "deploy";

        await vault.SaveHostCommand.ExecuteAsync(null);

        var before = vault.Hosts.Single();

        vault.ChooseHostCommand.Execute(before);
        vault.CopyChosenHostsToVaultCommand.Execute(null);

        vault.ChosenHostsAreBeingCopied.ShouldBeTrue(vault.Status);

        vault.SelectedChosenHostVault =
            vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);

        vault.Hosts.Count.ShouldBe(2, vault.Status);
        vault.Hosts.ShouldContain(row => row.VaultId == before.VaultId);
        vault.Hosts.ShouldContain(row => row.VaultId == sharedVaultId);
        vault.Hosts.ShouldAllBe(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
    }

    /// <remarks>
    /// The move is refused where it would have nowhere to go, by the command rather than by an empty
    /// picker — and the phone reads the same question to decide whether to draw the button at all.
    /// </remarks>
    [Fact]
    public async Task MovingAHostWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
    {
        await UnlockedAsync();

        var vault = shell.Vault!;

        await vault.LoadAsync(Token);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorUsername = "deploy";

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.SelectedHost = vault.Hosts.Single(
            host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));

        vault.CanMoveSelectedHost.ShouldBeFalse("the personal vault is the only one there is");

        vault.MoveHostCommand.Execute(null);

        vault.IsMovingHost.ShouldBeFalse();
        vault.MoveVaultChoices.ShouldBeEmpty();
        vault.Status.ShouldContain("only vault you can write to");
    }

    /// <remarks>
    /// <para>
    /// The gap the host's move kept running into. A key typed into a personal vault before the team existed
    /// is the key the team's machines authenticate with, and until this existed there was no way to get it
    /// across: the keychain could create and delete, so "moving" a key meant pasting the private half into a
    /// second item and deleting the first.
    /// </para>
    /// <para>
    /// <b>The re-aim is the half worth the test.</b> An item re-sealed into another vault lands with a new
    /// id, so without it every host bound to the key would be left naming a tombstone — and a host bound to
    /// something its vault no longer holds refuses to connect rather than falling back to a typed password.
    /// A move that did only the first half would look like a success and break two machines.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task MovingAKeyToAnotherVault_ReSealsItThereAndReAimsTheHostsThatUsedIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;
        await vault.LoadAsync(Token);

        var key = await AddKeyAsync(vault, "deploy");
        key.VaultId.ShouldNotBe(sharedVaultId, "it was typed into the personal vault");

        await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
        await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);

        vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == key.EntityId);
        vault.CanMoveSelectedItem.ShouldBeTrue("there is a second vault this session can write to");

        vault.MoveSelectedItemCommand.Execute(null);

        vault.IsMovingItem.ShouldBeTrue(vault.Status);
        vault.ShowsItemActions.ShouldBeFalse("the panel takes the place of EDIT and DELETE");
        vault.MoveItemVaultChoices.ShouldNotContain(choice => choice.VaultId == key.VaultId);

        // The count, before the move rather than after it. Two machines stop connecting if this is wrong.
        vault.MovingItemUsage.ShouldContain("2 hosts");

        vault.SelectedMoveItemVault =
            vault.MoveItemVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        // As the host's move does: the pass that follows every write is made to fail, so the sentence the
        // move itself wrote is still on the status line to be read.
        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmMoveItemCommand.ExecuteAsync(null);

        var moved = vault.Keys.ShouldHaveSingleItem();

        moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
        moved.EntityId.ShouldNotBe(key.EntityId, "an id belongs to one vault");
        moved.Key.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"), "the material crossed intact");

        vault.Hosts.Count.ShouldBe(2);
        vault.Hosts.ShouldAllBe(host => host.Host.SshKeyId == moved.EntityId);

        vault.Status.ShouldContain("Platform secrets");
        vault.Status.ShouldContain("2 hosts");
    }

    /// <remarks>
    /// The question this whole panel exists to ask. A binding resolves across vaults, so the moved host goes
    /// on working for the person who moved it either way — and for the colleagues it has just joined, a host
    /// whose key stayed behind is one they cannot connect with. Ticked, the key goes too and the host lands
    /// naming it by the id it landed with.
    /// </remarks>
    [Fact]
    public async Task MovingAHostWithItsKeyBrought_TakesTheKeyAcrossAndKeepsTheBinding()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var key = await AddKeyAsync(vault, "deploy");

        await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);

        vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
        vault.MoveHostCommand.Execute(null);

        vault.SelectedMoveVault =
            vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.HasABindingToBring.ShouldBeTrue(vault.Status);
        vault.BindingToBringQuestion.ShouldContain("deploy");
        vault.BindingToBringNote.ShouldContain("Nothing else", Case.Insensitive);
        vault.BringsTheBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");

        vault.BringsTheBindingAlong = true;

        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmMoveHostCommand.ExecuteAsync(null);

        var movedKey = vault.Keys.ShouldHaveSingleItem();
        var movedHost = vault.Hosts.ShouldHaveSingleItem();

        movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
        movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
        movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");

        vault.Status.ShouldContain("came with it");
        vault.BringsTheBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
    }

    /// <remarks>
    /// <para>
    /// ◆ <b>The same question from the phone's action bar, which is that head's only route to it since the
    /// connect card went.</b> It is asked in two shapes fewer than the desktop's: one host, because which key
    /// to carry is a fact about one machine and a selection of six has six answers; and a move rather than a
    /// copy, because taking the key out from under an original that is staying put would leave that original
    /// unable to connect.
    /// </para>
    /// <para>
    /// The three shapes are asserted in one test on purpose. What is being pinned is not that the box appears
    /// but that it appears in exactly one of them — a rule that only reads as a rule when the other two are
    /// beside it.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheActionBarAsksAboutTheKey_ForOneHostAndForAMoveOnly()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var key = await AddKeyAsync(vault, "deploy");

        await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
        await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);

        var one = vault.Hosts.Single(
            row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));

        vault.ChooseHostCommand.Execute(one);
        vault.MoveChosenHostsToVaultCommand.Execute(null);

        vault.SelectedChosenHostVault =
            vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.HasAChosenBindingToBring.ShouldBeTrue(vault.Status);
        vault.ChosenBindingToBringQuestion.ShouldContain("deploy");
        vault.ChosenBindingToBringNote.ShouldContain("one other host", Case.Insensitive);
        vault.BringsTheChosenBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");

        // A copy, which must never take the key: the original stays where it is and would be left bound to
        // something its own vault no longer holds.
        vault.CopyChosenHostsToVaultCommand.Execute(null);

        vault.SelectedChosenHostVault =
            vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.HasAChosenBindingToBring.ShouldBeFalse("a copy that moved the key would break the original");

        // And two hosts, where the question has two answers and no tick can carry them.
        vault.ToggleHostChoiceCommand.Execute(
            vault.Hosts.Single(row => string.Equals(row.Label, "prod-web", StringComparison.Ordinal)));

        vault.MoveChosenHostsToVaultCommand.Execute(null);

        vault.SelectedChosenHostVault =
            vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.HasAChosenBindingToBring.ShouldBeFalse("which key to carry is a fact about one machine");
    }

    /// <remarks>
    /// Ticked, from the phone. The desktop's own path is measured above; what this adds is that the batch
    /// command carries the key before it writes the host, so the host lands naming the id the key arrived
    /// with rather than a tombstone.
    /// </remarks>
    [Fact]
    public async Task MovingTheOneChosenHostWithItsKey_TakesTheKeyAcrossAndKeepsTheBinding()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var key = await AddKeyAsync(vault, "deploy");

        await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);

        vault.ChooseHostCommand.Execute(vault.Hosts.ShouldHaveSingleItem());
        vault.MoveChosenHostsToVaultCommand.Execute(null);

        vault.SelectedChosenHostVault =
            vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.BringsTheChosenBindingAlong = true;

        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);

        var movedKey = vault.Keys.ShouldHaveSingleItem();
        var movedHost = vault.Hosts.ShouldHaveSingleItem();

        movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
        movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
        movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");

        vault.Status.ShouldContain("came with it");
        vault.BringsTheChosenBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
        vault.IsChoosingHosts.ShouldBeFalse();
    }

    /// <remarks>
    /// The other answer, which is a real one: a key somebody does not want a team to hold stays where it is,
    /// and the sentence afterwards says what that means for everybody else in the destination. It is also
    /// what happens to anybody who presses MOVE without reading, which is why it is the unticked state.
    /// </remarks>
    [Fact]
    public async Task MovingAHostWithoutItsKey_LeavesTheKeyBehindAndSaysWhatThatCosts()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var key = await AddKeyAsync(vault, "deploy");

        await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);

        vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
        vault.MoveHostCommand.Execute(null);

        vault.SelectedMoveVault =
            vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmMoveHostCommand.ExecuteAsync(null);

        vault.Keys.ShouldHaveSingleItem().VaultId.ShouldBe(key.VaultId, "the key was not asked for");

        var moved = vault.Hosts.ShouldHaveSingleItem();

        moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
        moved.Host.SshKeyId.ShouldBe(key.EntityId, "the binding is kept — it resolves across vaults");

        vault.Status.ShouldContain("another vault");
    }

    /// <remarks>
    /// <para>
    /// The case where a host stops connecting without naming anything. A group lends its default key to
    /// everything filed under it, and a group belongs to the vault it is in — so the group stays behind, and
    /// a host that only inherited its key used to arrive naming nothing at all.
    /// </para>
    /// <para>
    /// The binding is written onto the host on the way across instead, which is the same key it
    /// authenticated with before the move. The move is also asked about it: the tick box reads the resolved
    /// binding, so an inherited key can be brought too.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task MovingAHostThatInheritsItsGroupsKey_WritesThatBindingOntoIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var key = await AddKeyAsync(vault, "deploy");

        vault.NewGroupCommand.Execute(null);
        vault.GroupEditorLabel = "Production";
        vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices
            .Single(choice => choice.EntityId == key.EntityId);

        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorUsername = "deploy";
        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));

        await vault.SaveHostCommand.ExecuteAsync(null);

        var before = vault.Hosts.ShouldHaveSingleItem();

        before.Host.SshKeyId.ShouldBeNull("the host names nothing; the group lends it");
        before.Authentication.ShouldBe("key");

        vault.SelectedHost = before;
        vault.MoveHostCommand.Execute(null);

        vault.SelectedMoveVault =
            vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.HasABindingToBring.ShouldBeTrue("an inherited key is still a key that can come along");

        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmMoveHostCommand.ExecuteAsync(null);

        var moved = vault.Hosts.ShouldHaveSingleItem();

        moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
        moved.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
        moved.Host.SshKeyId.ShouldBe(key.EntityId, "what it inherited is written onto it");
        moved.Authentication.ShouldBe("key", "it authenticates with what it did before the move");
    }

    /// <remarks>
    /// <para>
    /// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
    /// on the form the host is being typed into, rather than through a standing preference on another
    /// screen.
    /// </para>
    /// <para>
    /// It is asserted from the editor's own selection rather than the keychain screen's, because the two
    /// are deliberately separate — moving one must not move the other.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheHostEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        vault.NewHostCommand.Execute(null);

        vault.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");

        var personal = vault.SelectedTargetVault!;

        vault.EditorSelectedVault =
            vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts
            .Single(host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal))
            .VaultId
            .ShouldBe(sharedVaultId);

        vault.SelectedTargetVault.ShouldBe(
            personal, "the editor's picker is the host's, not the screen's standing preference");
    }

    /// <remarks>
    /// An existing host is not offered the picker at all. Moving an item between vaults is a delete and a
    /// retype — they are encrypted under different keys — so a control that appeared to offer it would be
    /// offering something no layer below can do.
    /// </remarks>
    [Fact]
    public async Task EditingAnExistingHost_DoesNotOfferToMoveItBetweenVaults()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;

        await vault.LoadAsync(Token);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.SelectedHost = vault.Hosts.Single(
            host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));

        vault.EditSelectedHostCommand.Execute(null);

        vault.IsEditing.ShouldBeTrue(vault.Status);
        vault.ShowsEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
    }

    /// <remarks>
    /// <para>
    /// A group is a shelf, and a shared vault is what makes it everybody's shelf. The assertions are the
    /// three things that were missing while the group list was the active vault's alone: it is listed at
    /// all, the row says which vault it is in, and a rename typed into it goes back to that vault rather
    /// than forking a second group of the new name into the personal one.
    /// </para>
    /// <para>
    /// Reloaded between the write and the read, so what is asserted is what came back out of the vault
    /// rather than the row the save left behind.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task AGroupFiledIntoASharedVault_IsListedThereAndRenamedThere()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        vault.NewGroupCommand.Execute(null);

        vault.ShowsGroupEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");

        vault.GroupEditorSelectedVault =
            vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.GroupEditorLabel = "production";

        await vault.SaveGroupCommand.ExecuteAsync(null);
        await vault.LoadAsync(Token);

        var group = vault.Groups.ShouldHaveSingleItem();

        group.VaultId.ShouldBe(sharedVaultId, vault.Status);
        group.VaultBadge.ShouldBe("PLATFORM SECRETS", "a card in a session holding two vaults says which");

        vault.EditGroupCommand.Execute(group);

        vault.ShowsGroupEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");

        vault.DrawerSubtitle.ShouldBe(
            "Platform secrets", "with no picker drawn, the header is what says whose shelf this is");

        vault.GroupEditorLabel = "live";

        await vault.SaveGroupCommand.ExecuteAsync(null);
        await vault.LoadAsync(Token);

        var renamed = vault.Groups.ShouldHaveSingleItem();

        renamed.Label.ShouldBe("live");
        renamed.VaultId.ShouldBe(sharedVaultId, "a rename must not fork a copy into the personal vault");
    }

    /// <remarks>
    /// <para>
    /// The group editor's picker is the group's, exactly as the host editor's is the host's: moving it must
    /// not move the keychain screen's standing preference, and moving that one must not move a group
    /// half-typed here.
    /// </para>
    /// <para>
    /// The second half is the one worth the test. The picker is read when the form opens and the vault is
    /// captured there, so a click on the other screen between typing the name and pressing ADD cannot
    /// redirect the group somebody was making.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheGroupEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var personal = vault.SelectedTargetVault!;

        vault.NewGroupCommand.Execute(null);

        vault.GroupEditorSelectedVault =
            vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.GroupEditorLabel = "production";

        // Moved back after the editor opened, the way a click on the keychain screen would. The group must
        // still land in the shared vault.
        vault.SelectedTargetVault = personal;

        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(sharedVaultId, vault.Status);

        vault.SelectedTargetVault.ShouldBe(
            personal, "the editor's picker is the group's, not the screen's standing preference");
    }

    /// <remarks>
    /// A parent belongs to one vault, and a group filed under one in another vault would be a level half
    /// the people holding the key cannot resolve — their hosts would inherit a port and a username from
    /// nothing. The same rule the host editor's group picker follows, one level up the same tree.
    /// </remarks>
    [Fact]
    public async Task AGroupsParentPicker_OffersOnlyTheVaultItIsGoingInto()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        // In the personal vault, which is where the standing preference points.
        vault.NewGroupCommand.Execute(null);
        vault.GroupEditorLabel = "estate";
        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.Groups.ShouldHaveSingleItem().Label.ShouldBe("estate", vault.Status);

        vault.NewGroupCommand.Execute(null);

        vault.GroupEditorParentChoices
            .Any(choice => string.Equals(choice.Label, "estate", StringComparison.Ordinal))
            .ShouldBeTrue("a group in the personal vault may be filed under a personal group");

        vault.GroupEditorSelectedVault =
            vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.GroupEditorParentChoices.ShouldHaveSingleItem()
            .EntityId.ShouldBeNull("only 'no parent' is left once the group is going somewhere else");
    }

    /// <remarks>
    /// <para>
    /// Moving the shelf rather than what is on it, which is the operation people were attempting one host at
    /// a time: a group cannot go anywhere alone, because the machines filed under it and the groups nested
    /// inside it are items of the vault it is leaving. All of them are re-sealed under the destination's key
    /// and all of them take new ids, so what this asserts is not only that they arrived but that the tree
    /// arrived — the child is still under the parent, and the host is still under the child, through two
    /// levels of ids that were rewritten on the way across.
    /// </para>
    /// <para>
    /// The parent the moved group was nested under is asserted <em>gone</em>, and that is the honest half. A
    /// parent belongs to the vault it is in, so carrying the reference would leave everybody else in the
    /// destination looking at a group hanging from nothing. It arrives at the top level and the sentence
    /// says so.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task MovingAGroupToAnotherVault_TakesItsHostsAndItsNestedGroupsWithIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        await SeedNestedShelfAsync(vault);

        var production = Named(vault, "production");

        production.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
        production.Group.ParentId.ShouldNotBeNull("it was nested, which is what has to stay behind");

        // Passed directly, the way MoveGroupFromHeading resolves and hands in a row now that there are no
        // group cards to select one from.
        vault.MoveGroupCommand.Execute(production);

        vault.IsMovingGroup.ShouldBeTrue(vault.Status);
        vault.MoveGroupVaultChoices.ShouldNotContain(choice => choice.VaultId == production.VaultId);

        vault.SelectedMoveGroupVault =
            vault.MoveGroupVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        // The pass that follows every write on this screen is made to fail, so that the move's own sentence
        // is still on the status line to be read — the same arrangement, and for the same reason, as the
        // host's move test above.
        server.SyncFailure = new IOException("The server is not answering.");

        await vault.ConfirmMoveGroupCommand.ExecuteAsync(null);

        var moved = Named(vault, "production");
        var nested = Named(vault, "web");

        moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
        moved.EntityId.ShouldNotBe(production.EntityId, "an id belongs to one vault");
        moved.Group.ParentId.ShouldBeNull("a parent belongs to the vault the group came from");

        nested.VaultId.ShouldBe(sharedVaultId, "a group inside it cannot be left in the other vault");
        nested.Group.ParentId.ShouldBe(moved.EntityId, "and it is still nested under the group it was in");

        var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));

        host.VaultId.ShouldBe(sharedVaultId, "the hosts came with the shelf");
        host.Host.GroupId.ShouldBe(nested.EntityId, "and are still filed where they were");

        // The group it was nested under is the one thing that stayed, and it stayed where it was.
        Named(vault, "estate").VaultId.ShouldBe(production.VaultId);

        vault.Status.ShouldContain("Platform secrets");
        vault.Status.ShouldContain("top level");
    }

    /// <remarks>
    /// The move is refused where it would have nowhere to go, by the command rather than by an empty picker
    /// — the same answer <c>MoveHostCommand</c> gives one level down, and the only place the question is
    /// asked. The menu entry is drawn either way, because a menu whose items came and went would be a menu
    /// whose items move.
    /// </remarks>
    [Fact]
    public async Task MovingAGroupWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
    {
        await UnlockedAsync();

        var vault = shell.Vault!;

        await vault.LoadAsync(Token);

        vault.NewGroupCommand.Execute(null);
        vault.GroupEditorLabel = "production";
        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.MoveGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());

        vault.IsMovingGroup.ShouldBeFalse();
        vault.MoveGroupVaultChoices.ShouldBeEmpty();
        vault.Status.ShouldContain("only vault you can write to");
    }

    /// <remarks>
    /// <para>
    /// The phone's route into the same move, and it is a different route rather than the same one reached
    /// differently. That head's list draws group headings rather than cards, a heading is deliberately not
    /// something it can select, and nothing there opens a group — so <c>GroupTarget</c> is null and a move
    /// that only read it would leave the menu entry doing nothing at all. The panel is aimed by the heading
    /// the menu was raised on instead.
    /// </para>
    /// <para>
    /// The innermost shelf is the one moved, because it is the one with a machine on it and so the one with
    /// a heading. That it arrives at the top level is the same rule the card's move follows: the group it
    /// was nested under belongs to the vault it is leaving.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task MovingAGroupFromItsHeading_TakesItsHostsWithNothingSelected()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        await SeedNestedShelfAsync(vault);

        var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
            row => string.Equals(row.Label, "web", StringComparison.Ordinal));

        vault.GroupTarget.ShouldBeNull("the phone selects no card and opens no group");

        vault.OpenGroupSheetCommand.Execute(heading);
        vault.MoveGroupFromHeadingCommand.Execute(heading);

        vault.GroupSheet.ShouldBeNull("the menu closes behind the entry that was pressed");
        vault.IsMovingGroup.ShouldBeTrue(vault.Status);
        vault.MovingGroupLabel.ShouldBe("web", "the panel names the shelf, having left the list behind");

        vault.SelectedMoveGroupVault =
            vault.MoveGroupVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        await vault.ConfirmMoveGroupCommand.ExecuteAsync(null);

        var moved = Named(vault, "web");

        moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
        moved.Group.ParentId.ShouldBeNull("a parent belongs to the vault the group came from");

        var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));

        host.VaultId.ShouldBe(sharedVaultId, "the machine came with the shelf");
        host.Host.GroupId.ShouldBe(moved.EntityId);
    }

    /// <summary>The group card with a given name, re-found because every row is replaced on every reload.</summary>
    private static HostGroupRowViewModel Named(VaultViewModel vault, string label) =>
        vault.Groups.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));

    /// <summary>
    /// Builds estate › production › web in the personal vault, with prod-db on the innermost shelf.
    /// </summary>
    /// <remarks>
    /// Three levels, because two would not tell a subtree that was walked from one that was assumed a single
    /// level deep — the middle group is the one that has to arrive with a rewritten parent and a rewritten
    /// child at once.
    /// </remarks>
    private static async Task SeedNestedShelfAsync(VaultViewModel vault)
    {
        await AddGroupAsync(vault, "estate", under: null);
        await AddGroupAsync(vault, "production", under: "estate");
        await AddGroupAsync(vault, "web", under: "production");

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, "web", StringComparison.Ordinal));

        await vault.SaveHostCommand.ExecuteAsync(null);
    }

    /// <summary>Adds a group, optionally nested under one already there.</summary>
    private static async Task AddGroupAsync(VaultViewModel vault, string label, string? under)
    {
        vault.NewGroupCommand.Execute(null);
        vault.GroupEditorLabel = label;

        if (under is not null)
        {
            vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
                choice => string.Equals(choice.Label, under, StringComparison.Ordinal));
        }

        await vault.SaveGroupCommand.ExecuteAsync(null);
    }

    /// <remarks>
    /// <para>
    /// A group lives in exactly one vault, so + NEW HOST inside one has to open on that vault as well as on
    /// that group. The two defaults were decided separately — the group from <see cref="VaultViewModel.GroupTarget"/>,
    /// the vault from the standing "new items go to" preference — so pressing the button inside a shared
    /// vault's group opened a form bound for the personal vault, with the group silently dropped by the
    /// picker that keeps the two in step. <c>GroupFilter</c> is written directly here rather than through
    /// <c>OpenGroupCommand</c>, which left with the group cards it used to open — see that property's own
    /// remarks.
    /// </para>
    /// <para>
    /// The second half is the same test read backwards, and it is what says which of the two wins. The
    /// preference is an answer for a host being made from nowhere in particular; the group somebody is
    /// standing inside is a better one, and it is the one the button was pressed in.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ANewHostInsideAGroup_IsMadeInThatGroupsVaultRatherThanTheStandingPreference()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        // In the personal vault, which is where a new group goes with nothing open and the preference
        // untouched. It is the one the second half of this test stands in.
        await AddGroupAsync(vault, "staging", under: null);

        var personalVaultId = Named(vault, "staging").VaultId;

        personalVaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with one vault");

        vault.NewGroupCommand.Execute(null);

        vault.GroupEditorSelectedVault =
            vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        vault.GroupEditorLabel = "production";
        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.GroupFilter = Named(vault, "production");

        vault.NewHostCommand.Execute(null);

        vault.EditorSelectedVault.ShouldNotBeNull(vault.Status).VaultId
            .ShouldBe(sharedVaultId, "the group the screen is standing in is in the shared vault");
        vault.EditorSelectedGroup.ShouldNotBeNull().Label
            .ShouldBe("production", "and the group survives, having a vault it can be resolved in");

        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        await vault.SaveHostCommand.ExecuteAsync(null);

        var host = vault.Hosts.ShouldHaveSingleItem();

        host.VaultId.ShouldBe(sharedVaultId, vault.Status);
        host.Host.GroupId.ShouldBe(Named(vault, "production").EntityId);

        // And backwards: the preference names the shared vault and the open group is in the personal one.
        vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);

        vault.GroupFilter = Named(vault, "staging");

        vault.NewHostCommand.Execute(null);

        vault.EditorSelectedVault.ShouldNotBeNull(vault.Status).VaultId
            .ShouldBe(personalVaultId, "the group somebody is standing in beats the picker they set once");
        vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("staging");
    }

    /// <remarks>
    /// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
    /// shared vault has to stay there, because hosts are read across every readable vault and so come back;
    /// so does a group, since its list spans them too. Tags are not — the editable list is the active
    /// vault's alone, like buckets — so a tag filed anywhere else would be created, pushed, reported as
    /// added and then invisible, with nothing on the keychain screen able to rename or delete it and no
    /// active-vault switcher to go and find it with.
    /// </remarks>
    [Fact]
    public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var sharedVaultId = vaults.SelectedVault!.VaultId;
        var vault = shell.Vault!;

        await vault.LoadAsync(Token);

        vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");

        vault.SelectedTargetVault = vault.TargetVaults.Single(
            choice => choice.VaultId == sharedVaultId);

        vault.NewTagCommand.Execute(null);
        vault.TagEditorLabel = "eu-west-1";
        await vault.SaveTagCommand.ExecuteAsync(null);

        vault.Tags.ShouldHaveSingleItem().Label
            .ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
    }

    /// <remarks>
    /// The screen's answer to "who can actually open this". Asserted after somebody has been added
    /// rather than before, because an empty list proves nothing about whether the call was made.
    /// </remarks>
    [Fact]
    public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddAccount("bob@example.com", "Bob Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        // Two, and the creator is the other: their own self-grant is what makes a vault they just made
        // readable at all, so a list that left it out would show the one person who can certainly open
        // this vault as somebody who cannot.
        vaults.Grants.Count.ShouldBe(2, vaults.Status);

        var holder = vaults.Grants.Single(row => row.UserId == colleague);

        holder.IsLive.ShouldBeTrue(vaults.Status);
        holder.State.ShouldBe("holds a key");
    }

    /// <remarks>
    /// A role change is authorization only. The status line has to say so, because the obvious reading
    /// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
    /// key they were already wrapped. Withdrawing that is a separate act.
    /// </remarks>
    [Fact]
    public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddAccount("bob@example.com", "Bob Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);

        await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);

        vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
        vaults.Status.ShouldContain("does not withdraw a vault key");
    }

    /// <remarks>
    /// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
    /// itself rather than letting the server do it: a button that produced a server error would be
    /// reporting a rule the screen already knew.
    /// </remarks>
    [Fact]
    public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddAccount("bob@example.com", "Bob Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);

        await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);

        vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
        vaults.Status.ShouldContain("HAND OVER");
    }

    /// <remarks>
    /// <para>
    /// Both halves, because a transfer that only promoted the recipient would leave the vault owned
    /// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
    /// a single transaction to make impossible, so the client test asserts the same pair.
    /// </para>
    /// <para>
    /// It also goes through the armed confirmation rather than calling the command directly, since
    /// arming and confirming are where the target ids are carried — and carrying them on the selection
    /// instead is how a confirmation ends up applied to whatever was clicked last.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task HandingOverAVault_MakesThemTheOwnerAndTheCallerAnAdmin()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddAccount("bob@example.com", "Bob Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        vaults.NewMemberEmail = "bob@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);

        vaults.HandOverCommand.Execute(null);

        vaults.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
        vaults.ShowsVaultActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");

        await vaults.ConfirmActionCommand.ExecuteAsync(null);

        vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
        vaults.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
        vaults.IsConfirming.ShouldBeFalse();
    }

    /// <remarks>
    /// Renaming reaches the rest of the shell, which is the half a client can get wrong quietly: the name
    /// is drawn on the badge of every host card in a session holding more than one vault, in the
    /// file-this-into picker, and in the tab strip's menu.
    /// </remarks>
    [Fact]
    public async Task RenamingAVault_ReachesTheKeychainScreensPickerToo()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vaultId = vaults.SelectedVault!.VaultId;

        vaults.RenameVaultCommand.Execute(null);
        vaults.EditVaultName = "Platform";

        await vaults.SaveVaultNameCommand.ExecuteAsync(null);

        vaults.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
        vaults.Status.ShouldContain("re-encrypted");

        await shell.Vault!.LoadAsync(Token);

        shell.Vault.TargetVaults
            .Single(choice => choice.VaultId == vaultId)
            .Name
            .ShouldBe("Platform");
    }

    /// <remarks>
    /// v5c-2: the settings Vaults page draws one card per vault with no list selection to lean on, so
    /// <c>RenameVaultRow</c>/<c>DeleteVaultRow</c> select the row first and then hand off to the commands
    /// above — this proves the hand-off selects the right vault and reaches the same form.
    /// </remarks>
    [Fact]
    public async Task RenameVaultRow_SelectsTheCardThenOpensTheSameFormRenameVaultDoes()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");
        var shared = vaults.SelectedVault!;

        // A different vault selected first, so the row argument is what actually decides which one the
        // form is about rather than whatever was already selected.
        vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);

        vaults.RenameVaultRowCommand.Execute(shared);

        vaults.SelectedVault.ShouldBe(shared);
        vaults.IsRenamingVault.ShouldBeTrue();
        vaults.EditVaultName.ShouldBe("Platform secrets");
    }

    [Fact]
    public async Task DeleteVaultRow_SelectsTheCardThenArmsTheSameConfirmationDeleteVaultDoes()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");
        var shared = vaults.SelectedVault!;

        vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);

        vaults.DeleteVaultRowCommand.Execute(shared);

        vaults.SelectedVault.ShouldBe(shared);
        vaults.IsConfirming.ShouldBeTrue();
        vaults.PendingAction!.Question.ShouldContain("Platform secrets");
    }

    /// <remarks>
    /// The settings page's members panel: pressing the card's members icon on a vault that is not already
    /// selected has to select it first, or the panel would open over whichever vault the list last landed
    /// on rather than the one that was actually clicked.
    /// </remarks>
    [Fact]
    public async Task OpenMembersPanel_SelectsTheVaultItWasOpenedForAndReadsItsMembers()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");
        var shared = vaults.SelectedVault!;

        vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
        vaults.IsMembersPanelOpen.ShouldBeFalse();

        vaults.OpenMembersPanelCommand.Execute(shared);

        vaults.IsMembersPanelOpen.ShouldBeTrue();
        vaults.SelectedVault.ShouldBe(shared);

        // OpenMembersPanel's own selection assignment starts a read nothing here can await — see
        // OnSelectedVaultChanged — so this reads it again through LoadAsync, which is awaited, rather than
        // racing the fire-and-forget one.
        await vaults.LoadAsync(Token);

        vaults.Members.ShouldContain(member => member.IsSelf);

        vaults.CloseMembersPanelCommand.Execute(null);
        vaults.IsMembersPanelOpen.ShouldBeFalse();
    }

    /// <remarks>
    /// <para>
    /// <b>An address with no account is a refusal, and the sentence has to say what to do about it.</b>
    /// This used to issue an invitation from the same button — a standing instruction that the next
    /// account signing in with that address joined the vault. It does not any more: an address is not a
    /// way in, and only an account somebody named can be added.
    /// </para>
    /// <para>
    /// The status assertion is the point of the test. "No such account" on its own is a dead end that
    /// reads as a typo, so what is pinned is that the message names the address and says the remedy —
    /// they sign in here once — and that nothing is held for them in the meantime.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task AddingAnAddressWithNoAccount_IsRefusedAndSaysWhatHasToHappenFirst()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        vaults.NewMemberEmail = "newcomer@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.Members.ShouldHaveSingleItem("nobody joined — there was nobody to add");

        vaults.Status.ShouldContain("newcomer@example.com");
        vaults.Status.ShouldContain("sign in here once");
        vaults.Status.ShouldContain("Nothing is held for them");
    }

    /// <remarks>
    /// <para>
    /// The regression this whole path was rewritten for. An account exists from its owner's first
    /// authenticated request and publishes no key until they choose a passphrase on their own machine,
    /// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
    /// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD refused
    /// somebody who was standing right there.
    /// </para>
    /// <para>
    /// So the assertion is that they are a member, and that the row says what is true of them — no key,
    /// so nothing can be shared with them yet.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;
        var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");

        await CreateVaultAsync(vaults, "Platform secrets");

        vaults.NewMemberEmail = "carol@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.Members.Count.ShouldBe(2, vaults.Status);

        var member = vaults.Members.Single(row => row.UserId == colleague);

        member.Email.ShouldBe("carol@example.com");

        // The label the user asked to see, and the reason SHARE KEY is not the next step.
        member.KeyState.ShouldContain("no key yet");

        vaults.Status.ShouldContain("Added");
        vaults.Status.ShouldContain("no key yet");
    }

    /// <remarks>
    /// The refusal does not clear the box, and that is the half worth pinning separately. A message
    /// telling somebody to come back once that person has signed in is a message they act on later — with
    /// the address gone they would have to find it again, and the natural reading of an emptied box is
    /// that the add went through.
    /// </remarks>
    [Fact]
    public async Task AnAddressThatWasRefused_IsStillInTheBox()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        vaults.NewMemberEmail = "stranger@example.com";
        await vaults.AddMemberCommand.ExecuteAsync(null);

        vaults.NewMemberEmail.ShouldBe("stranger@example.com");
    }

    /// <remarks>
    /// <para>
    /// A reload rebuilds the vault list and reselects, so a reload that changed the selection — creating
    /// the first shared vault is exactly that — used to leave two reads of the same membership list in
    /// flight: the one the reload awaits, and one the selection handler started on its own. Both clear the
    /// member list and then both append to it, so every member was drawn twice. On a vault nobody has been
    /// added to yet, whose only member is its owner, that read as the owner being in it twice.
    /// </para>
    /// <para>
    /// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
    /// memory each read finishes before the next begins, so the duplicate never appears and the bug
    /// survives the test. Holding the read open is what makes this behave like a server.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task CreatingAVault_ReadsItsMembersOnce()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);

        vaults.NewVaultCommand.Execute(null);
        vaults.NewVaultName = "Platform secrets";

        var gate = new TaskCompletionSource();

        server.MemberReadGate = gate;

        var create = vaults.CreateVaultCommand.ExecuteAsync(null);

        // Asserted while the read is still in flight: that is the only moment at which a second read
        // started by the selection handler is distinguishable from the reload's own.
        server.MemberReads.ShouldBe(1, "a reload reads the selected vault's members once");

        gate.SetResult();

        await create;

        vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
    }

    /// <remarks>
    /// Through the form rather than straight at the command, because the name is what the form is for —
    /// and because the form is now the only way in: there is no separate "make a team" step behind it.
    /// </remarks>
    /// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
    private static string PrivateKey(string body) =>
        $"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";

    /// <summary>Puts one key in whatever vault the keychain is filing into, and hands back its row.</summary>
    private static async Task<SshKeyRowViewModel> AddKeyAsync(VaultViewModel vault, string label)
    {
        vault.NewKeyCommand.Execute(null);

        vault.KeyEditorLabel = label;
        vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");

        await vault.SaveKeyCommand.ExecuteAsync(null);

        vault.IsEditingKey.ShouldBeFalse(vault.Status);

        return vault.Keys.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
    }

    /// <summary>Creates a host that authenticates with one key, by choosing it in the editor.</summary>
    private static async Task AddHostBoundToKeyAsync(VaultViewModel vault, string label, Guid keyId)
    {
        vault.NewHostCommand.Execute(null);

        vault.EditorLabel = label;
        vault.EditorHostname = $"{label}.internal";
        vault.EditorUsername = "deploy";
        vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
            .Single(choice => choice.EntityId == keyId);

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.IsEditing.ShouldBeFalse(vault.Status);
    }

    private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
    {
        await vaults.LoadAsync(Token);

        vaults.NewVaultCommand.Execute(null);
        vaults.NewVaultName = name;

        await vaults.CreateVaultCommand.ExecuteAsync(null);

        vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
        vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
        vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
    }

    /// <remarks>
    /// <para>
    /// Sharing a snippet, which is a move like a host's and simpler in exactly one way: a snippet crosses
    /// whole. It has no group, no tags and no key binding — nothing on it points at an item of the vault it
    /// came from — so the assertion the host's move makes about what was left behind has no analogue, and
    /// the one worth making instead is that <em>nothing</em> was lost, the flag that decides whether it
    /// presses Enter for you least of all.
    /// </para>
    /// <para>
    /// The new id is asserted for the reason the host's test gives: one entity id in two vaults would make
    /// the destination's row and the source's tombstone the same row.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task MovingASnippetToAnotherVault_ReSealsItThereAndCarriesItWhole()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var snippets = SnippetsOver(vault);

        await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: true);

        var before = Snippet(snippets, "restart the api");

        before.VaultId.ShouldNotBe(sharedVaultId);

        snippets.Selected = before;
        snippets.CanMove.ShouldBeTrue("there is a second vault this session can write to");

        snippets.MoveCommand.Execute(null);

        snippets.IsMoving.ShouldBeTrue(snippets.Status);
        snippets.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);

        snippets.SelectedMoveVault =
            snippets.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        // The pass that follows every write is made to fail, so the move's own sentence is still on the
        // status line to be read. See the host's move test, which does this for the same reason.
        server.SyncFailure = new IOException("The server is not answering.");

        await snippets.ConfirmMoveCommand.ExecuteAsync(null);

        var after = Snippet(snippets, "restart the api");

        after.VaultId.ShouldBe(sharedVaultId, vault.Status);
        after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
        after.Snippet.Command.ShouldBe("sudo systemctl restart dodossh-api");
        after.Snippet.RunsOnInsert.ShouldBeTrue("the flag that decides whether it presses Enter came too");

        snippets.Selected?.EntityId.ShouldBe(after.EntityId, "the pane follows the snippet it moved");
        snippets.Status.ShouldContain("Platform secrets");
    }

    /// <remarks>
    /// Refused by the command rather than by an empty picker, and the phone reads the same question to
    /// decide whether to draw the button at all.
    /// </remarks>
    [Fact]
    public async Task MovingASnippetWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
    {
        await UnlockedAsync();

        var vault = shell.Vault!;

        await vault.LoadAsync(Token);

        var snippets = SnippetsOver(vault);

        await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);

        snippets.Selected = Snippet(snippets, "uptime");

        snippets.CanMove.ShouldBeFalse("the personal vault is the only one there is");

        snippets.MoveCommand.Execute(null);

        snippets.IsMoving.ShouldBeFalse();
        snippets.MoveVaultChoices.ShouldBeEmpty();
        snippets.Status.ShouldContain("only vault you can write to");
    }

    /// <remarks>
    /// The picker the snippet editor grew, and the thing it is for: choosing at the moment a snippet is
    /// written, on the form it is being typed into. A command is worth sharing precisely when somebody else
    /// would otherwise be retyping it, so filing it into the team's vault at that moment is the ordinary
    /// case rather than an afterthought.
    /// </remarks>
    [Fact]
    public async Task TheSnippetEditorFilesANewSnippetIntoTheVaultChosenOnIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var snippets = SnippetsOver(vault);

        snippets.NewCommand.Execute(null);

        snippets.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");

        snippets.EditorSelectedVault =
            snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);

        snippets.EditorLabel = "rotate the certs";
        snippets.EditorCommand = "sudo certbot renew";

        await snippets.SaveCommand.ExecuteAsync(null);

        Snippet(snippets, "rotate the certs").VaultId.ShouldBe(sharedVaultId, snippets.Status);
    }

    /// <remarks>
    /// <para>
    /// The bug the per-editor latch exists to prevent, and the reason the screen could not simply keep
    /// writing to the active vault once its list spanned several. An update sent to the active vault would
    /// create a second snippet there and leave the team's original untouched: a fork that shows up only
    /// when a colleague asks why the correction never arrived.
    /// </para>
    /// <para>
    /// The count is the assertion. One snippet with that label, in the vault it started in.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task EditingASharedSnippet_WritesBackToItsOwnVaultRatherThanForkingACopy()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await CreateVaultAsync(vaults, "Platform secrets");

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        var snippets = SnippetsOver(vault);

        snippets.NewCommand.Execute(null);
        snippets.EditorSelectedVault =
            snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
        snippets.EditorLabel = "drain the node";
        snippets.EditorCommand = "kubectl drain node-1";

        await snippets.SaveCommand.ExecuteAsync(null);

        snippets.Selected = Snippet(snippets, "drain the node");
        snippets.EditCommand.Execute(null);

        snippets.ShowsEditorVaultChoice.ShouldBeFalse("an existing snippet's vault is not a field of the form");

        snippets.EditorCommand = "kubectl drain node-1 --ignore-daemonsets";

        await snippets.SaveCommand.ExecuteAsync(null);

        var edited = Snippet(snippets, "drain the node");

        edited.VaultId.ShouldBe(sharedVaultId, "the edit went back to the vault it came from");
        edited.Snippet.Command.ShouldBe("kubectl drain node-1 --ignore-daemonsets");
    }

    /// <summary>The snippet with a given name, re-found because every row is replaced on every reload.</summary>
    private static SnippetRowViewModel Snippet(SnippetsViewModel snippets, string label) =>
        snippets.Visible.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));

    /// <summary>The snippets screen over a vault, with no terminal to insert into.</summary>
    /// <remarks>
    /// Insert is not what this suite is about — see <c>ShellFlowTests</c> for that — so the target is empty
    /// and the delivery is a stub that would report success if anything asked it to.
    /// </remarks>
    private static SnippetsViewModel SnippetsOver(VaultViewModel vault) =>
        new(vault, () => InsertTarget.None, (_, _, _, _) => Task.FromResult(true));

    private static async Task AddSnippetAsync(
        SnippetsViewModel snippets,
        string label,
        string command,
        bool runs)
    {
        snippets.NewCommand.Execute(null);
        snippets.EditorLabel = label;
        snippets.EditorCommand = command;
        snippets.EditorRunsOnInsert = runs;

        await snippets.SaveCommand.ExecuteAsync(null);
    }

    /// <remarks>
    /// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
    /// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
    /// that entry is what the client verifies its own directory answer against.
    /// </remarks>
    private async Task UnlockedAsync()
    {
        await shell.StartAsync(Token);
        await shell.SignInCommand.ExecuteAsync(null);

        shell.Passphrase = Passphrase;
        shell.ConfirmPassphrase = Passphrase;
        await shell.EnrollCommand.ExecuteAsync(null);

        shell.RecoveryCodeWrittenDown = true;
        shell.ConfirmRecoveryCodeCommand.Execute(null);

        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
    }
}
