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; /// /// Making a vault by naming it, and switching one off without switching it out. /// /// /// /// Two features that meet in the same place. Creating 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. /// /// /// Hiding 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. /// /// 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; /// 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(StringComparer.Ordinal)), ssh, TimeProvider.System); shell = NewShell(); return ValueTask.CompletedTask; } /// 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 ---- /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// 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. /// [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); } /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// 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. /// [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 ---- /// /// 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. /// [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"); } /// /// 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. /// [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)); } /// /// The regression this whole design is shaped around. 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. /// [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"); } /// /// 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. /// [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); } /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// 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. /// [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); } /// /// 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 /// group and tag lists 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. /// [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"); } /// /// 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. /// [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(server), TimeProvider.System, NSubstitute.Substitute.For(), CheapProfile); /// Names a vault, from the form the tab strip's menu opens. private async Task 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 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 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); } /// Switches a vault off through the menu, as the tab strip does. 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); } /// 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); } }