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; /// /// Vaults, from the side that holds the keys: make one, add somebody, and wrap its key to them. /// /// /// /// 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; sharing is a decision the client /// makes about whether to trust a public key the server just handed it, 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. /// /// /// So the fake server keeps a real key log — chained with the same KeyLogChain 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. /// /// /// It was TeamSharingTests, 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. /// /// 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; /// 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(StringComparer.Ordinal)), ssh, TimeProvider.System); shell = new MainWindowViewModel( paths, caches, workspace, knownHosts, deviceKeys, (_, _) => Task.FromResult(server), TimeProvider.System, NSubstitute.Substitute.For(), CheapProfile); 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. } } /// /// 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. /// [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"); } /// /// 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. /// [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); } /// /// /// 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. /// /// /// 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. /// /// [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]); } /// /// 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. /// [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); } /// /// /// 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. /// /// /// 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. /// /// [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"); } /// /// 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. /// [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(); } /// /// 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. /// [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"); } /// /// /// 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 /me 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. /// /// /// 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. /// /// [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"); } /// /// /// 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. /// /// /// 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. /// /// [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); } /// /// 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. /// [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); } /// /// 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. /// [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"); } /// /// 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. /// [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); } /// /// /// 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. /// /// /// 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. /// /// [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); } /// /// /// ◆ The phone's action bar sending a whole selection across, in one run. 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. /// /// /// 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. /// /// [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); } /// /// Two hosts for the run above: one in the personal vault under a group, one already in the destination. /// /// /// 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. /// 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); } /// /// 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. /// [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)); } /// /// 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. /// [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"); } /// /// /// 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. /// /// /// The re-aim is the half worth the test. 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. /// /// [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"); } /// /// 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. /// [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"); } /// /// /// ◆ The same question from the phone's action bar, which is that head's only route to it since the /// connect card went. 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. /// /// /// 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. /// /// [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"); } /// /// 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. /// [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(); } /// /// 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. /// [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"); } /// /// /// 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. /// /// /// 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. /// /// [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"); } /// /// /// 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. /// /// /// 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. /// /// [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"); } /// /// 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. /// [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"); } /// /// /// 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. /// /// /// 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. /// /// [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.SelectedGroup = group; vault.EditGroupCommand.Execute(null); 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"); } /// /// /// 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. /// /// /// 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. /// /// [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"); } /// /// 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. /// [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"); } /// /// /// 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. /// /// /// The parent the moved group was nested under is asserted gone, 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. /// /// [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"); // As the card's menu does before it runs the command; see HostsScreen.OnGroupContextRequested. vault.SelectedGroup = production; vault.MoveGroupCommand.Execute(null); 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.SelectedGroup?.EntityId.ShouldBe(moved.EntityId, "the buttons follow the group they moved"); vault.Status.ShouldContain("Platform secrets"); vault.Status.ShouldContain("top level"); } /// /// The move is refused where it would have nowhere to go, by the command rather than by an empty picker /// — the same answer MoveHostCommand 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. /// [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.SelectedGroup = vault.Groups.ShouldHaveSingleItem(); vault.MoveGroupCommand.Execute(null); vault.IsMovingGroup.ShouldBeFalse(); vault.MoveGroupVaultChoices.ShouldBeEmpty(); vault.Status.ShouldContain("only vault you can write to"); } /// /// /// 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 GroupTarget 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. /// /// /// 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. /// /// [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().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); } /// The group card with a given name, re-found because every row is replaced on every reload. private static HostGroupRowViewModel Named(VaultViewModel vault, string label) => vault.Groups.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal)); /// /// Builds estate › production › web in the personal vault, with prod-db on the innermost shelf. /// /// /// 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. /// 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); } /// Adds a group, optionally nested under one already there. 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); } /// /// /// Dragging a host card onto a group card is the one gesture that files a host without opening its /// editor, and it can now be aimed across a vault boundary, because both grids draw every readable /// vault. The write it would make is the exact thing the host editor's group picker was fixed to /// prevent: an id only the other vault's holders can resolve. /// /// /// Refused and said so, rather than quietly treated as "no group" — the user is plainly filing /// something, and unfiling it instead would be the wrong answer delivered silently. /// /// [Fact] public async Task AHostDraggedOntoAnotherVaultsGroup_IsRefusedRatherThanFiledUnderIt() { 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.GroupEditorSelectedVault = vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId); vault.GroupEditorLabel = "production"; await vault.SaveGroupCommand.ExecuteAsync(null); // The host stays in the personal vault, which is where a new one goes without being told otherwise. vault.NewHostCommand.Execute(null); vault.EditorLabel = "prod-db"; vault.EditorHostname = "db.internal"; await vault.SaveHostCommand.ExecuteAsync(null); var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal)); var group = vault.Groups.Single(row => row.VaultId == sharedVaultId); host.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault"); await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group.EntityId)); vault.Status.ShouldContain("its own vault"); vault.Hosts .Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal)) .Host.GroupId .ShouldBeNull("the host is left where it was rather than filed under an unresolvable group"); } /// /// /// The same rule as the drag above, arrived at from the other end: 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 came from the screen, 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. /// /// /// 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. /// /// [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.OpenGroupCommand.Execute(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.OpenGroupCommand.Execute(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"); } /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// /// 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. /// /// /// 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. /// /// [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(); } /// /// 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. /// [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"); } /// /// /// An address with no account is a refusal, and the sentence has to say what to do about it. /// 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. /// /// /// 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. /// /// [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"); } /// /// /// 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. /// /// /// 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. /// /// [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"); } /// /// 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. /// [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"); } /// /// /// 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. /// /// /// 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. /// /// [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"); } /// /// 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. /// /// The armour a key is stored in, which this suite never parses and only round-trips. private static string PrivateKey(string body) => $"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n"; /// Puts one key in whatever vault the keychain is filing into, and hands back its row. private static async Task 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)); } /// Creates a host that authenticates with one key, by choosing it in the editor. 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); } /// /// /// 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 nothing was lost, the flag that decides whether it /// presses Enter for you least of all. /// /// /// 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. /// /// [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"); } /// /// 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. /// [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"); } /// /// 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. /// [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); } /// /// /// 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. /// /// /// The count is the assertion. One snippet with that label, in the vault it started in. /// /// [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"); } /// The snippet with a given name, re-found because every row is replaced on every reload. private static SnippetRowViewModel Snippet(SnippetsViewModel snippets, string label) => snippets.Visible.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal)); /// The snippets screen over a vault, with no terminal to insert into. /// /// Insert is not what this suite is about — see ShellFlowTests for that — so the target is empty /// and the delivery is a stub that would report success if anything asked it to. /// 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); } /// /// 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. /// 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); } }