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>
/// Making a vault by naming it, and switching one off without switching it out.
/// </summary>
/// <remarks>
/// <para>
/// Two features that meet in the same place. <b>Creating</b> a vault takes a name and nothing else — the
/// team that owns it is derived and made behind it — so the half worth testing is the failure between the
/// two calls, where the team exists and the vault does not.
/// </para>
/// <para>
/// <b>Hiding</b> one is a preference about what is drawn, and every test below that says "still" is
/// guarding the line it must not cross. A hidden vault goes on syncing, its keys go on authenticating
/// hosts that are still on screen, and it stays choosable as somewhere to file a new item. What changes is
/// the lists a person reads, and nothing else.
/// </para>
/// </remarks>
public sealed class VaultVisibilityTests : 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 ClientPaths paths = 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-visibility-{Guid.CreateVersion7():N}");
        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 = NewShell();

        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.
        }
    }

    // ---- Making one ----

    /// <remarks>
    /// The whole feature in one test. A name is all that is asked for, and what comes back is a vault this
    /// machine can already write to, with a membership list this account owns — which is what makes the
    /// rest of the screen, members and roles and key holders, apply to it.
    /// </remarks>
    [Fact]
    public async Task CreatingAVaultByNameAlone_MakesTheMembershipListForItAndOwnsIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);
        await CreateVaultAsync("Platform secrets");

        // Read from the server rather than off the screen: the membership list behind a vault is not a
        // thing this screen shows any more, and that is exactly why it is worth asserting on directly.
        var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();

        team.Name.ShouldBe("Platform secrets");
        team.Slug.ShouldBe("platform-secrets", "the slug is derived rather than asked for");
        team.Role.ShouldBe(TeamMemberRole.Owner);

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

        vault.IsOwned.ShouldBeTrue(vaults.Status);
        shell.Vault!.Session.ReadableVaults
            .Select(row => row.VaultId)
            .ShouldContain(vault.VaultId, "a vault made here is usable here, without a relock");
    }

    /// <remarks>
    /// Where the flow lands, and it is the point of routing the tab strip's entry through this screen: the
    /// next thing anybody making a shared vault wants is the people, and the people are here.
    /// </remarks>
    [Fact]
    public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheVaultsScreen()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);
        await CreateVaultAsync("Platform secrets");

        vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
        vaults.SelectedVault.Name.ShouldBe("Platform secrets");
        vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
    }

    /// <remarks>
    /// The failure between the two calls. The membership list is real and is kept for the retry — the
    /// sentence has to carry the whole state rather than "creating the vault failed", because pressing
    /// CREATE again is what finishes the job and cancelling is what undoes it.
    /// </remarks>
    [Fact]
    public async Task AVaultCreateThatFailsAfterTheMembershipList_KeepsItAndSaysSo()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);

        server.VaultCreateFailures = 1;

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

        await vaults.CreateVaultCommand.ExecuteAsync(null);

        (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
        vaults.Vaults.ShouldNotContain(
            row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));

        vaults.IsCreatingVault.ShouldBeTrue("the form stays open so CREATE can be pressed again");
        vaults.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");

        vaults.Status.ShouldContain("was not created");
        vaults.Status.ShouldContain("Press CREATE again");
    }

    /// <remarks>
    /// The retry, and the reason the id is generated once and held rather than per attempt. A second
    /// membership list would be one nothing on this screen could show and nobody could remove.
    /// </remarks>
    [Fact]
    public async Task RetryingAfterTheVaultCreateFailed_ReusesTheMembershipListRatherThanMakingASecond()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);

        server.VaultCreateFailures = 1;

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

        await vaults.CreateVaultCommand.ExecuteAsync(null);

        var teamId = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId;

        // Pressed again on the form that is still open, which is exactly what the message tells the user
        // to do.
        await vaults.CreateVaultCommand.ExecuteAsync(null);

        (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
        vaults.Vaults.ShouldContain(
            row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
        vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
    }

    /// <remarks>
    /// Cancelling takes the half-made membership list with it, which is the one place this application
    /// tidies up on the user's behalf. The reason is that nothing on the screen can reach it: a membership
    /// list with no vault has no row, so leaving it would leave something the user can neither see nor
    /// remove.
    /// </remarks>
    [Fact]
    public async Task CancellingAfterTheVaultCreateFailed_TakesTheMembershipListWithIt()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);

        server.VaultCreateFailures = 1;

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

        await vaults.CreateVaultCommand.ExecuteAsync(null);
        await vaults.CancelNewVaultCommand.ExecuteAsync(null);

        (await server.Teams.ListTeamsAsync(Token))
            .ShouldBeEmpty("the membership list nobody was shown is not left behind");
    }

    /// <remarks>
    /// A slug is derived, so a collision is something the user cannot see coming and cannot fix by editing
    /// a field they were never shown. One retry with a disambiguated slug, and the name they typed is left
    /// alone — the name is theirs, the slug is a handle.
    /// </remarks>
    [Fact]
    public async Task ASlugAlreadyInUse_IsRetriedOnceWithADisambiguatedOne()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);

        server.TakenSlugs.Add("platform-secrets");

        await CreateVaultAsync("Platform secrets");

        var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();

        team.Name.ShouldBe("Platform secrets", "the name is what the user typed");
        team.Slug.ShouldStartWith("platform-secrets-");
        team.Slug.ShouldNotBe("platform-secrets");
    }

    /// <remarks>
    /// A name written in a script with no a-z or 0-9 in it leaves nothing to slugify. It still has to be a
    /// vault a person can make, so the fallback is an id rather than a refusal pointing at a field that
    /// does not exist.
    /// </remarks>
    [Fact]
    public async Task AVaultNameWithNothingSluggableInIt_StillGetsAUsableSlug()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);
        await CreateVaultAsync("διαχείριση");

        var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();

        team.Name.ShouldBe("διαχείριση");
        team.Slug.ShouldStartWith("vault-");
        team.Slug.Length.ShouldBeGreaterThan("vault-".Length);
    }

    // ---- Switching one off ----

    /// <remarks>
    /// What the switch is for. Somebody in four teams does not want four teams' machines in front of them
    /// all day, and this is the list that gets shorter.
    /// </remarks>
    [Fact]
    public async Task HidingATeamVault_TakesItsHostsOffTheHostsScreen()
    {
        await UnlockedAsync();

        var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
        var vault = shell.Vault!;

        vault.VisibleHosts.ShouldContain(row => row.VaultId == teamVaultId);

        await HideAsync(teamVaultId);

        vault.VisibleHosts.ShouldNotContain(row => row.VaultId == teamVaultId);
        vault.Hosts.ShouldContain(
            row => row.VaultId == teamVaultId,
            "the unfiltered list stays whole — everything that resolves a binding reads it");

        vault.HasVisibleHosts.ShouldBeFalse("the personal vault has nothing in it in this test");
        vault.NoVisibleHostsMessage.ShouldContain("switched off");
    }

    /// <remarks>
    /// The keychain is vault-scoped too, so the same switch has to reach it. The table is what is filtered
    /// rather than the typed lists behind it — see the test below for why that distinction is load-bearing.
    /// </remarks>
    [Fact]
    public async Task HidingATeamVault_TakesItsKeysOffTheKeychain()
    {
        await UnlockedAsync();

        var teamVaultId = await VaultWithAKeyAsync("Platform secrets", "deploy");
        var vault = shell.Vault!;

        vault.VaultItems.ShouldContain(row => string.Equals(row.Name, "deploy", StringComparison.Ordinal));

        await HideAsync(teamVaultId);

        vault.VaultItems.ShouldNotContain(
            row => string.Equals(row.Name, "deploy", StringComparison.Ordinal));
    }

    /// <remarks>
    /// <b>The regression this whole design is shaped around.</b> A host in one vault may authenticate with
    /// a key filed in another, and the only authentication resolution in the product reads the keychain's
    /// typed list. Filtering that list rather than the table would make switching a vault off break
    /// connections to hosts still on screen — a preference about reading turning into an outage.
    /// </remarks>
    [Fact]
    public async Task HidingATeamVault_LeavesAHostThatBindsItsKeyStillConnectable()
    {
        await UnlockedAsync();

        var teamVaultId = await VaultWithAKeyAsync("Platform secrets", "deploy");
        var vault = shell.Vault!;

        var key = vault.Keys.Single(row => string.Equals(row.Label, "deploy", StringComparison.Ordinal));

        await HideAsync(teamVaultId);

        vault.Keys.ShouldContain(
            row => row.EntityId == key.EntityId,
            "a hidden vault's keys still have to resolve for the hosts that name them");

        vault.NewHostCommand.Execute(null);

        vault.EditorAuthenticationChoices.ShouldContain(
            choice => choice.EntityId == key.EntityId,
            "and still have to be offerable, or the binding could never be repaired");
    }

    /// <remarks>
    /// Hiding is about reading. A destination you cannot choose is a vault you cannot put anything in, so
    /// switching a team's forty hosts out of the way must not quietly stop you filing into it.
    /// </remarks>
    [Fact]
    public async Task HidingATeamVault_LeavesItInTheSaveTargetPicker()
    {
        await UnlockedAsync();

        var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");

        await HideAsync(teamVaultId);

        shell.Vault!.TargetVaults.Select(choice => choice.VaultId).ShouldContain(teamVaultId);
    }

    /// <remarks>
    /// The line the feature must not cross. A vault that stopped syncing because somebody tidied it off
    /// their screen would be found out weeks later, by a host that was never there.
    /// </remarks>
    [Fact]
    public async Task HidingATeamVault_DoesNotStopItSyncing()
    {
        await UnlockedAsync();

        var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
        var vault = shell.Vault!;

        await HideAsync(teamVaultId);

        vault.Session.ReadableVaults.Select(row => row.VaultId).ShouldContain(teamVaultId);

        // And it still accepts writes and still pushes them, which is the part a user would notice.
        var before = server.LiveRowCount;

        await AddHostAsync(vault, teamVaultId, "prod-cache", "cache.internal");

        server.LiveRowCount.ShouldBe(before + 1, vault.Status);
        vault.PendingChanges.ShouldBe(0, "saving pushes, hidden or not");
    }

    /// <remarks>
    /// The pin list describes a pin nothing dials as unused, which is a hint that invites withdrawing
    /// trust. That answer is taken over every host rather than the shown ones, so switching a vault off
    /// cannot turn a pin somebody relies on into one they are being nudged to delete.
    /// </remarks>
    [Fact]
    public async Task HidingATeamVault_StillCountsItsHostsWhenDecidingWhichPinsNothingDials()
    {
        await UnlockedAsync();

        var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");
        var vault = shell.Vault!;

        // Trusted into the personal vault, which is where the handshake writes; the host it is for lives in
        // the team's. That crossing is exactly the case the count has to survive.
        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);

        await vault.LoadAsync(Token);

        vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();

        await HideAsync(teamVaultId);

        vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost
            .ShouldBeTrue("hiding a vault must not make a pin look abandoned");
    }

    /// <remarks>
    /// It is a preference, so it belongs to the machine rather than to the session. Somebody who set a
    /// vault aside yesterday has not asked to be shown it again this morning.
    /// </remarks>
    [Fact]
    public async Task HidingAVault_SurvivesLockingAndUnlocking()
    {
        await UnlockedAsync();

        var teamVaultId = await VaultWithAHostAsync("Platform secrets", "prod-db");

        await HideAsync(teamVaultId);
        await shell.LockCommand.ExecuteAsync(null);

        shell.VaultToggles.ShouldBeEmpty("the switches belong to the session that was open");

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

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

        shell.VaultToggles.Single(toggle => toggle.VaultId == teamVaultId).IsShown.ShouldBeFalse();
        shell.Vault!.VisibleHosts.ShouldNotContain(row => row.VaultId == teamVaultId);
    }

    /// <remarks>
    /// It is drawn in the menu and ticked, because a vault missing from a list of vaults reads as something
    /// having gone wrong — and it cannot be switched off, because snippets, logs, buckets and the editable
    /// tag list are all read from it alone. Switching it off would empty half the application rather than
    /// filter it, so the refusal says why instead of doing nothing. The group list is no longer among them:
    /// it spans every readable vault, and hiding one drops that vault's cards and headings the way it drops
    /// its hosts.
    /// </remarks>
    [Fact]
    public async Task ThePersonalVaultIsListedAndCannotBeHidden()
    {
        await UnlockedAsync();
        await CreateVaultAsync("Platform secrets");

        var personal = shell.VaultToggles.Single(toggle => toggle.IsPersonal);

        personal.IsShown.ShouldBeTrue();
        personal.CanHide.ShouldBeFalse();

        await shell.ToggleVaultCommand.ExecuteAsync(personal);

        shell.VaultToggles.Single(toggle => toggle.IsPersonal).IsShown.ShouldBeTrue();
        shell.StatusMessage.ShouldContain("always shown");
    }

    /// <remarks>
    /// <para>
    /// The two halves of a group, and hiding a vault has to move exactly one of them. The cards and
    /// headings are a list a person reads, so a hidden vault's group leaves it — a folder that cannot be
    /// opened onto anything is worse than no folder. What a group also is is a port, a username and a
    /// binding lent to the hosts beneath it, and that must not move: those hosts are still in
    /// <c>Hosts</c>, which is what the connect path and the transfers screen read.
    /// </para>
    /// <para>
    /// Asserted through the resolved port rather than through the map directly, because the resolved port
    /// is what a connection actually dials. A hidden vault whose hosts silently fell back to 22 would be
    /// this split having collapsed, and nothing on screen would say so.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task HidingAVault_TakesItsGroupCardsButNotWhatItsHostsDial()
    {
        await UnlockedAsync();

        await shell.Vaults.LoadAsync(Token);

        var teamVaultId = await CreateVaultAsync("Platform secrets");
        var vault = shell.Vault!;

        await vault.LoadAsync(Token);

        vault.NewGroupCommand.Execute(null);

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

        vault.GroupEditorLabel = "production";
        vault.GroupEditorDefaultPort = 2222;

        await vault.SaveGroupCommand.ExecuteAsync(null);

        await AddHostAsync(vault, teamVaultId, "prod-db", "db.internal");

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

        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));

        await vault.SaveHostCommand.ExecuteAsync(null);

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

        await HideAsync(teamVaultId);
        await vault.LoadAsync(Token);

        vault.Groups.ShouldBeEmpty("a hidden vault's groups are cards onto hosts that are not drawn");

        vault.Hosts
            .Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
            .Resolved.Port.Value
            .ShouldBe(2222, "hiding a vault must never change what one of its hosts dials");
    }

    /// <remarks>
    /// The switches are the readable vaults, personal first. A vault whose grant awaits re-wrap has nothing
    /// that would decrypt, so a switch for it would do nothing at all.
    /// </remarks>
    [Fact]
    public async Task TheVaultMenu_ListsEveryReadableVaultWithThePersonalOneFirst()
    {
        await UnlockedAsync();

        shell.HasVaultSwitches.ShouldBeFalse("one vault is a menu with nothing to choose between");

        await CreateVaultAsync("Platform secrets");

        shell.HasVaultSwitches.ShouldBeTrue();
        shell.VaultToggles.Count.ShouldBe(2);
        shell.VaultToggles[0].IsPersonal.ShouldBeTrue();
        // SHARED rather than TEAM: a team is no longer something the person reading this menu has been
        // shown, so the word names what the switch is actually about.
        shell.VaultToggles[1].Display.ShouldBe("Platform secrets · SHARED");
    }

    // ---- Helpers ----

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

    /// <summary>Names a vault, from the form the tab strip's menu opens.</summary>
    private async Task<Guid> CreateVaultAsync(string name)
    {
        var vaults = shell.Vaults;

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

        await vaults.CreateVaultCommand.ExecuteAsync(null);

        vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);

        return vaults.Vaults.Single(row => string.Equals(row.Name, name, StringComparison.Ordinal))
            .VaultId;
    }

    private async Task<Guid> VaultWithAHostAsync(string vaultName, string hostLabel)
    {
        await shell.Vaults.LoadAsync(Token);

        var vaultId = await CreateVaultAsync(vaultName);

        await AddHostAsync(shell.Vault!, vaultId, hostLabel, "db.internal");

        return vaultId;
    }

    private async Task<Guid> VaultWithAKeyAsync(string vaultName, string keyLabel)
    {
        await shell.Vaults.LoadAsync(Token);

        var vaultId = await CreateVaultAsync(vaultName);
        var vault = shell.Vault!;

        await vault.LoadAsync(Token);

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

        vault.NewKeyCommand.Execute(null);
        vault.KeyEditorLabel = keyLabel;
        vault.KeyEditorPrivateKey =
            "-----BEGIN OPENSSH PRIVATE KEY-----\nMATERIAL\n-----END OPENSSH PRIVATE KEY-----\n";

        await vault.SaveKeyCommand.ExecuteAsync(null);

        vault.IsEditingKey.ShouldBeFalse(vault.Status);

        return vaultId;
    }

    private async Task AddHostAsync(
        VaultViewModel vault,
        Guid vaultId,
        string label,
        string hostname)
    {
        await vault.LoadAsync(Token);

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

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = label;
        vault.EditorHostname = hostname;
        vault.EditorUsername = "deploy";

        await vault.SaveHostCommand.ExecuteAsync(null);

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

    /// <summary>
    /// Switches a vault off through <see cref="MainWindowViewModel.ToggleVaultCommand"/> — the command
    /// behind a vault row in the rail's own user popover now, not the tab strip's old vault menu, which is
    /// gone; see <c>NavRail.axaml</c>. This project is deliberately Avalonia-free, so what this file proves
    /// is the command's own effect; that the popover's row is wired to call it is
    /// <c>DodoSSH.Client.App.Layout.Tests.NavRailTests</c>' business.
    /// </summary>
    private async Task HideAsync(Guid vaultId)
    {
        var toggle = shell.VaultToggles.Single(row => row.VaultId == vaultId);

        await shell.ToggleVaultCommand.ExecuteAsync(toggle);

        shell.VaultToggles.Single(row => row.VaultId == vaultId).IsShown
            .ShouldBeFalse(shell.StatusMessage);
    }

    /// <inheritdoc cref="TeamSharingTests.UnlockedAsync" />
    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);
    }
}
