Files

2407 lines
97 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Vaults, from the side that holds the keys: make one, add somebody, and wrap its key to them.
/// </summary>
/// <remarks>
/// <para>
/// The reason this suite exists rather than leaving sharing to the server's own tests is that the
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
/// lookup with a key of their own.
/// </para>
/// <para>
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
/// sharing works, not that verification does.
/// </para>
/// <para>
/// It was <c>TeamSharingTests</c>, and the screen it drives stopped being about teams: a vault is what
/// gets made and named, and the membership list behind it is made with it. The team is still what the
/// server authorises against, which is why the assertions about roles and hand-over are all still here —
/// they are the same operations, reached through the vault they apply to.
/// </para>
/// </remarks>
public sealed class VaultSharingTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeVaultServer server = new();
private readonly FakeSshConnectionFactory ssh = new();
private string directory = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
directory = Path.Combine(Path.GetTempPath(), $"dodossh-vaults-{Guid.CreateVersion7():N}");
var paths = new ClientPaths(directory);
caches = ClientCacheFactory.ForFile(paths.CacheFile);
knownHosts = new VaultKnownHostStore();
deviceKeys = new FakeDeviceKeyStore();
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
ssh,
TimeProvider.System);
shell = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
(_, _) => Task.FromResult<IVaultServer>(server),
TimeProvider.System,
NSubstitute.Substitute.For<ISftpSessionFactory>(),
CheapProfile);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
caches.Dispose();
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// A cache file the process has not finished releasing. The directory is under the temp path
// and named per run, so leaving it costs a few kilobytes and never collides.
}
}
/// <remarks>
/// The whole point of a shared vault, in one test. Adding somebody wraps the vault to them, so the
/// status line names what they were given rather than what is still owed — and the grant is on the
/// server before the add has finished reporting.
/// </remarks>
[Fact]
public async Task AddingSomebody_WrapsTheVaultToThemStraightAway()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
var vaultId = vaults.SelectedVault!.VaultId;
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.Count.ShouldBe(2, vaults.Status);
server.IssuedGrants.ShouldContainKey(
(vaultId, colleague),
"adding somebody to a vault is what shares it with them");
vaults.Status.ShouldContain("Platform secrets");
}
/// <remarks>
/// The manual path still works and is still worth having: a vault whose key this machine did not
/// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
/// somebody who already holds the key is the same call, and the server replaces the row rather than
/// adding a second one.
/// </remarks>
[Fact]
public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
await vaults.ShareVaultCommand.ExecuteAsync(null);
var vaultId = vaults.SelectedVault!.VaultId;
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
vaults.Status.ShouldContain("Shared");
// The one thing verification cannot promise, said in the same breath as the success.
vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
}
/// <remarks>
/// <para>
/// The other half of the same idea. Removing somebody withdraws their grants — which only blocks
/// future reads — so the vault is rotated in the same breath and the new key goes to the people who
/// are left. From that moment nothing written is readable to the person who went.
/// </para>
/// <para>
/// The remaining member is given the earlier generation as well as the new one, which is what keeps
/// the vault's existing items readable to them: a rotation re-keys the vault, not its contents.
/// </para>
/// </remarks>
[Fact]
public async Task RemovingSomebody_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var leaving = server.AddAccount("bob@example.com", "Bob Example");
var staying = server.AddAccount("carol@example.com", "Carol Example");
await CreateVaultAsync(vaults, "Platform secrets");
var vaultId = vaults.SelectedVault!.VaultId;
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
{
vaults.NewMemberEmail = address;
await vaults.AddMemberCommand.ExecuteAsync(null);
}
vaults.Members.Count.ShouldBe(3, vaults.Status);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == leaving);
await vaults.RemoveMemberCommand.ExecuteAsync(null);
vaults.Status.ShouldContain("Rotated", customMessage: vaults.Status);
vaults.Status.ShouldContain("Platform secrets");
// The last act of a rotation is moving what is already stored onto the new key. Proven by the
// bytes in DodoSSH.Client.Sync.Tests; what this asserts is that the shell asks for it at all,
// and says which of the two guarantees the user has ended up with.
vaults.Status.ShouldContain("re-sealed under the new key", customMessage: vaults.Status);
// Gone entirely, at every generation. A revocation that left the history behind would leave them
// able to read everything written before they went, from a copy of the ciphertext.
server.GenerationsGranted(vaultId, leaving).ShouldBeEmpty();
// And the member who stayed holds both: the new key for what comes next, the old one for what
// is already stored under it.
server.GenerationsGranted(vaultId, staying).ShouldBe([1u, 2u]);
}
/// <remarks>
/// Somebody added after a rotation is given every generation the sharing machine holds, not only the
/// newest. A vault shared as one key would open to a list of items that will not decrypt, which
/// reads as corruption rather than as the missing grant it is.
/// </remarks>
[Fact]
public async Task AddingSomebodyToARotatedVault_HandsThemItsHistoryAsWell()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var first = server.AddAccount("bob@example.com", "Bob Example");
var second = server.AddAccount("carol@example.com", "Carol Example");
await CreateVaultAsync(vaults, "Platform secrets");
var vaultId = vaults.SelectedVault!.VaultId;
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
// with a history rather than one that has only ever had a single key.
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
await vaults.RemoveMemberCommand.ExecuteAsync(null);
vaults.NewMemberEmail = "carol@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
}
/// <remarks>
/// <para>
/// The test this whole design exists for. A server that wants to read a shared vault only has to
/// answer one directory lookup with a key it holds the private half of — so the client reads the
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
/// offered is in there unchanged.
/// </para>
/// <para>
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
/// worse than no check at all, because the interface would have said it was verified.
/// </para>
/// </remarks>
[Fact]
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
await CreateVaultAsync(vaults, "Platform secrets");
// Before the add, because the add now shares. Both routes to a wrap have to refuse, and a test
// that corrupted the log afterwards would be asserting about the second one only.
server.CorruptKeyLog = true;
vaults.NewMemberEmail = "mallory@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
var vaultId = vaults.SelectedVault!.VaultId;
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
vaults.Status.ShouldContain("Could not share");
vaults.Status.ShouldContain("key log");
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
await vaults.ShareVaultCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
vaults.Status.ShouldContain("Did not share");
vaults.Status.ShouldContain("key log");
}
/// <remarks>
/// A vault created here is usable here, without a relock. The key was generated in this process, so
/// making the user lock and unlock to reach the vault they just made would be asking them to work
/// around bookkeeping.
/// </remarks>
[Fact]
public async Task AVaultCreatedHere_IsImmediatelyReadableAndWritable()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vaultId = vaults.SelectedVault!.VaultId;
var session = shell.Vault!.Session;
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
// And it is offered as somewhere to file a new item, which is what makes it worth having.
await shell.Vault.LoadAsync(Token);
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
shell.Vault.HasVaultChoice.ShouldBeTrue();
}
/// <remarks>
/// <para>
/// And it reaches a host editor that was already open, which is very nearly the only way a vault gets made:
/// somebody starts adding a host, realises it belongs to the team rather than to them, and goes to make
/// somewhere to put it. The editor's picker is its own — see <c>VaultViewModel.EditorVaultChoices</c> — and
/// it was filled when the editor opened, so the vault they had just made for this host was the one place
/// they could not file it without throwing the form away.
/// </para>
/// <para>
/// The second half is what stops the fix being worse than the bug: the refill adds the entry and leaves the
/// selection alone, so a vault appearing cannot move a half-typed host into it.
/// </para>
/// </remarks>
[Fact]
public async Task AVaultCreatedWithAHostEditorOpen_IsOfferedAsSomewhereToFileThatHost()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await CreateVaultAsync(shell.Vaults, "Platform secrets");
var vaultId = shell.Vaults.SelectedVault!.VaultId;
vault.IsEditing.ShouldBeTrue("making a vault must not close the host editor");
vault.EditorLabel.ShouldBe("prod-db", "nor discard what has been typed into it");
vault.ShowsEditorVaultChoice.ShouldBeTrue(
"a second writable vault is what makes the picker worth drawing at all");
vault.EditorVaultChoices.Select(choice => choice.VaultId).ShouldContain(vaultId);
vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal
.ShouldBeTrue("the refill must not move the host into the vault that has just appeared");
// Choosing it, on the other hand, files the host there — which is what the picker was for.
vault.EditorSelectedVault = vault.EditorVaultChoices.Single(
choice => choice.VaultId == vaultId);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem().VaultId.ShouldBe(vaultId, vault.Status);
}
/// <remarks>
/// The snippet editor's picker is a copy of the same list — see
/// <c>SnippetsViewModel.BuildEditorVaultChoices</c> — and went stale in exactly the same way. It watches the
/// list rather than the reload, because this screen has always been a wrapper over the vault's collections
/// and has no reload of its own to hang off.
/// </remarks>
[Fact]
public async Task AVaultCreatedWithASnippetEditorOpen_IsOfferedAsSomewhereToFileThatSnippet()
{
await UnlockedAsync();
var vault = shell.Vault!;
var snippets = SnippetsOver(vault);
snippets.NewCommand.Execute(null);
snippets.EditorLabel = "restart the api";
snippets.EditorCommand = "sudo systemctl restart dodossh-api";
await CreateVaultAsync(shell.Vaults, "Platform secrets");
var vaultId = shell.Vaults.SelectedVault!.VaultId;
snippets.IsEditing.ShouldBeTrue("making a vault must not close the snippet editor");
snippets.EditorCommand.ShouldBe(
"sudo systemctl restart dodossh-api",
"nor discard what has been typed into it");
snippets.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between now");
snippets.EditorVaultChoices.Select(choice => choice.VaultId).ShouldContain(vaultId);
snippets.EditorSelectedVault.ShouldNotBeNull().IsPersonal
.ShouldBeTrue("the refill must not move the snippet into the vault that has just appeared");
snippets.EditorSelectedVault = snippets.EditorVaultChoices.Single(
choice => choice.VaultId == vaultId);
await snippets.SaveCommand.ExecuteAsync(null);
Snippet(snippets, "restart the api").VaultId.ShouldBe(vaultId, snippets.Status);
}
/// <remarks>
/// Making a vault makes exactly one membership list, and this is the assertion that the two-step create
/// has not started leaking them: the screen no longer offers to make one on its own, so a second one
/// per vault would be invisible in the interface and visible only to an operator.
/// </remarks>
[Fact]
public async Task CreatingAVault_MakesOneMembershipListWithTheCallerAsItsOwner()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
server.TeamCreates.ShouldBe(1);
var row = vaults.Vaults.Single(
vault => string.Equals(vault.Name, "Platform secrets", StringComparison.Ordinal));
row.IsShared.ShouldBeTrue("a vault made here is one other people can be added to");
row.IsOwned.ShouldBeTrue(vaults.Status);
row.SharedWithOtherVaults.ShouldBe(0, "it was made with a membership list of its own");
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
/// <remarks>
/// <para>
/// Sharing from the receiving end, which is the half that has to happen on somebody else's machine and
/// the half that was missing. A vault wrapped to this account appears in <c>/me</c> and nowhere else —
/// there is no push channel — so a client that never re-read that list showed nothing, indefinitely,
/// while the server and the grant were both perfectly correct.
/// </para>
/// <para>
/// Readable rather than merely listed, because those are two different failures with the same symptom:
/// a row that cannot be opened is a vault whose key never arrived, and this asserts the wrap was taken
/// into the keyring. The switch is asserted too — it is built by the shell rather than by the vault, so
/// it is the one thing a pass could admit a vault without redrawing.
/// </para>
/// </remarks>
[Fact]
public async Task AVaultSomebodyElseShared_ArrivesOnTheNextSynchronisation()
{
await UnlockedAsync();
var colleague = server.AddAccount("bob@example.com", "Bob Example");
var vaultId = server.ShareVaultWithMe("Platform secrets", colleague);
var vault = shell.Vault.ShouldNotBeNull();
await vault.SyncCommand.ExecuteAsync(null);
vault.Session.ReadableVaults.ShouldContain(
row => row.VaultId == vaultId,
"a vault shared with this account arrives on a synchronisation pass, with its key");
shell.VaultToggles.ShouldContain(
toggle => toggle.VaultId == vaultId,
"the tab strip's vault menu is built by the shell and has to be told");
await shell.Vaults.LoadAsync(Token);
var row = shell.Vaults.Vaults.Single(vault => vault.VaultId == vaultId);
row.IsReadable.ShouldBeTrue(shell.Vaults.Status);
row.IsOwned.ShouldBeFalse("somebody else made this one");
}
/// <remarks>
/// <para>
/// Deleting a shared vault, which is an admin's operation and the only one on this screen that cannot
/// be undone. It has to take three things with it: the vault, everybody's key to it — including the
/// people it was shared with — and this machine's own copy of the row, so the list is right before the
/// next refresh rather than after it.
/// </para>
/// <para>
/// The status line is asserted for what it says about the limit rather than for its wording. A message
/// implying that deletion reaches a colleague's laptop would be the one dishonest sentence this screen
/// could print; see ADR 0001.
/// </para>
/// </remarks>
[Fact]
public async Task DeletingAVault_TakesItAndEverybodysKeyToIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
var vaultId = vaults.SelectedVault!.VaultId;
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
vaults.CanDeleteSelected.ShouldBeTrue("an admin may delete a shared vault");
vaults.DeleteVaultCommand.Execute(null);
var question = vaults.PendingAction.ShouldNotBeNull("deletion is never carried out unasked");
question.Consequence.ShouldContain(
"already synced", Case.Insensitive, "the one limit this must not leave implied");
await vaults.ConfirmActionCommand.ExecuteAsync(null);
vaults.Vaults.ShouldNotContain(row => row.VaultId == vaultId, vaults.Status);
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
shell.Vault!.Session.Vaults.ShouldNotContain(
row => row.VaultId == vaultId,
"the machine that deleted it does not wait for a refresh to stop listing it");
shell.VaultToggles.ShouldNotContain(toggle => toggle.VaultId == vaultId);
}
/// <remarks>
/// The one vault deletion cannot reach, refused by the screen rather than by the server: everything
/// filed nowhere else lives in it and nothing can make another, so the button is not offered and the
/// command says why if something reaches it anyway.
/// </remarks>
[Fact]
public async Task ThePersonalVault_CannotBeDeleted()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
vaults.SelectedVault = vaults.Vaults.Single(vault => vault.IsPersonal);
vaults.CanDeleteSelected.ShouldBeFalse();
vaults.DeleteVaultCommand.Execute(null);
vaults.PendingAction.ShouldBeNull("nothing was armed");
vaults.Status.ShouldContain("cannot be deleted");
vaults.Vaults.ShouldContain(vault => vault.IsPersonal);
}
/// <remarks>
/// The personal vault is in the list, is marked as the one thing it is, and offers nothing to share:
/// the server refuses a grant on one outright, so a screen that let somebody try would be sending them
/// at a refusal.
/// </remarks>
[Fact]
public async Task ThePersonalVault_IsListedAndCannotBeSharedWithAnybody()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
var personal = vaults.Vaults.ShouldHaveSingleItem();
personal.IsPersonal.ShouldBeTrue();
personal.IsShared.ShouldBeFalse();
personal.RoleLabel.ShouldBe("PERSONAL");
vaults.SelectedVault = personal;
vaults.SelectedIsShared.ShouldBeFalse();
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.ShouldBeEmpty();
vaults.Status.ShouldContain("cannot be shared");
}
/// <remarks>
/// Filing into a shared vault has to be chosen and has to stick. The bug this guards is the obvious
/// one: an editor that read the picker at save time rather than at open time, so changing the picker
/// with a half-typed host on screen would move it.
/// </remarks>
[Fact]
public async Task AHostFiledIntoASharedVault_StaysThere()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.SelectedTargetVault =
vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
// Moved back after the editor opened. The host must still land in the shared vault: the keychain
// screen's picker seeds the editor's and stops mattering from there.
vault.SelectedTargetVault =
vault.TargetVaults.First(choice => choice.VaultId != sharedVaultId);
await vault.SaveHostCommand.ExecuteAsync(null);
var row = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
row.VaultId.ShouldBe(sharedVaultId);
}
/// <remarks>
/// <para>
/// Moving a host into a shared vault, which is the operation that used to require deleting it and
/// typing it again: the two vaults are encrypted under different keys, so what happens underneath is a
/// re-seal into one and a tombstone in the other. The host has to arrive intact, be gone from where it
/// was, and carry a new id — one entity id in two vaults would make the destination's row and the
/// source's tombstone the same row.
/// </para>
/// <para>
/// The group is asserted cleared, and that is the half worth a test rather than a comment. A group is
/// an item of the vault the host is leaving, so a host that carried the reference across would resolve
/// it on this machine — groups are resolved over every readable vault — and dangle for everybody else
/// in the destination. The mover and their colleagues would be looking at two different hosts.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAHostToAnotherVault_ReSealsItThereAndLeavesItsGroupBehind()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
// In the personal vault, under a group of its own, which is what the move has to leave behind.
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "Production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
var before = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
before.VaultId.ShouldNotBe(sharedVaultId);
before.Host.GroupId.ShouldNotBeNull("the host was filed under a group before the move");
vault.SelectedHost = before;
vault.CanMoveSelectedHost.ShouldBeTrue("there is a second vault this session can write to");
vault.MoveHostCommand.Execute(null);
vault.IsMovingHost.ShouldBeTrue(vault.Status);
vault.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// The pass that follows every write on this screen is made to fail, so that the move's own sentence
// is still on the status line to be read. That is not a contrivance to dodge a race: a successful
// pass reports what it moved and supersedes the confirmation of every save, delete and move alike —
// pre-existing behaviour of the whole screen — and the state asserted here is the one where the
// sentence matters most, because nothing has reached the server yet.
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var after = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
after.VaultId.ShouldBe(sharedVaultId, vault.Status);
after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
after.Host.Hostname.ShouldBe("db.internal");
after.Host.Username.ShouldBe("deploy");
after.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
vault.SelectedHost?.EntityId.ShouldBe(after.EntityId, "the pane follows the host it moved");
vault.Status.ShouldContain("Platform secrets");
vault.Status.ShouldContain("group", Case.Insensitive);
}
/// <remarks>
/// <para>
/// ◆ <b>The phone's action bar sending a whole selection across, in one run.</b> The single-host move
/// above is the desktop's; this is the same write over a set, and the thing worth pinning is that it
/// obeys the same rule — the group and the tags are items of the vault being left, so nothing carries
/// them across.
/// </para>
/// <para>
/// A host already in the destination is skipped rather than refusing the whole run, and the sentence
/// afterwards says how many were left alone. Eleven machines with one that had nowhere to go must not do
/// nothing at all and then report about the wrong ten.
/// </para>
/// </remarks>
[Fact]
public async Task MovingTheChosenHostsToAnotherVault_TakesThemAllAndSkipsTheOnesAlreadyThere()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
await SeedForTheChosenHostMoveAsync(vault, sharedVaultId);
foreach (var row in vault.Hosts.ToList())
{
vault.ChooseHostCommand.Execute(row);
}
vault.ChosenHostCount.ShouldBe(2);
vault.MoveChosenHostsToVaultCommand.Execute(null);
vault.IsSendingChosenHostsToAVault.ShouldBeTrue(vault.Status);
vault.ChosenHostsAreBeingCopied.ShouldBeFalse();
// Every writable vault, because the selection spans two of them — there is no single vault to leave
// out, and shrinking the list to the intersection would offer nothing at all.
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// As the single-host move's own test does, and for the reason written there: a successful pass
// reports what it pushed and supersedes the run's own sentence, which is what is being read here.
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);
vault.Hosts.Count.ShouldBe(2, "nothing was duplicated on the way across");
vault.Hosts.ShouldAllBe(row => row.VaultId == sharedVaultId);
vault.Hosts
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
vault.Status.ShouldContain("Platform secrets");
vault.Status.ShouldContain("left alone", Case.Insensitive);
}
/// <summary>
/// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
/// </summary>
/// <remarks>
/// The first is what the move has to strip a group off on the way across; the second is the row that has
/// to be skipped rather than turned into a second copy of itself.
/// </remarks>
private static async Task SeedForTheChosenHostMoveAsync(VaultViewModel vault, Guid sharedVaultId)
{
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "Production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault =
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.EditorLabel = "prod-web";
vault.EditorHostname = "web.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
}
/// <remarks>
/// The other verb behind the same picker. What makes it worth its own test is the half that is not a
/// move: the original stays where it is, so a host shared with a team is still readable by the person
/// who shared it.
/// </remarks>
[Fact]
public async Task CopyingAChosenHostToAnotherVault_LeavesTheOriginalWhereItIs()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
var before = vault.Hosts.Single();
vault.ChooseHostCommand.Execute(before);
vault.CopyChosenHostsToVaultCommand.Execute(null);
vault.ChosenHostsAreBeingCopied.ShouldBeTrue(vault.Status);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);
vault.Hosts.Count.ShouldBe(2, vault.Status);
vault.Hosts.ShouldContain(row => row.VaultId == before.VaultId);
vault.Hosts.ShouldContain(row => row.VaultId == sharedVaultId);
vault.Hosts.ShouldAllBe(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
}
/// <remarks>
/// The move is refused where it would have nowhere to go, by the command rather than by an empty
/// picker — and the phone reads the same question to decide whether to draw the button at all.
/// </remarks>
[Fact]
public async Task MovingAHostWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
{
await UnlockedAsync();
var vault = shell.Vault!;
await vault.LoadAsync(Token);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
vault.CanMoveSelectedHost.ShouldBeFalse("the personal vault is the only one there is");
vault.MoveHostCommand.Execute(null);
vault.IsMovingHost.ShouldBeFalse();
vault.MoveVaultChoices.ShouldBeEmpty();
vault.Status.ShouldContain("only vault you can write to");
}
/// <remarks>
/// <para>
/// The gap the host's move kept running into. A key typed into a personal vault before the team existed
/// is the key the team's machines authenticate with, and until this existed there was no way to get it
/// across: the keychain could create and delete, so "moving" a key meant pasting the private half into a
/// second item and deleting the first.
/// </para>
/// <para>
/// <b>The re-aim is the half worth the test.</b> An item re-sealed into another vault lands with a new
/// id, so without it every host bound to the key would be left naming a tombstone — and a host bound to
/// something its vault no longer holds refuses to connect rather than falling back to a typed password.
/// A move that did only the first half would look like a success and break two machines.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAKeyToAnotherVault_ReSealsItThereAndReAimsTheHostsThatUsedIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
key.VaultId.ShouldNotBe(sharedVaultId, "it was typed into the personal vault");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == key.EntityId);
vault.CanMoveSelectedItem.ShouldBeTrue("there is a second vault this session can write to");
vault.MoveSelectedItemCommand.Execute(null);
vault.IsMovingItem.ShouldBeTrue(vault.Status);
vault.ShowsItemActions.ShouldBeFalse("the panel takes the place of EDIT and DELETE");
vault.MoveItemVaultChoices.ShouldNotContain(choice => choice.VaultId == key.VaultId);
// The count, before the move rather than after it. Two machines stop connecting if this is wrong.
vault.MovingItemUsage.ShouldContain("2 hosts");
vault.SelectedMoveItemVault =
vault.MoveItemVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// As the host's move does: the pass that follows every write is made to fail, so the sentence the
// move itself wrote is still on the status line to be read.
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveItemCommand.ExecuteAsync(null);
var moved = vault.Keys.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.EntityId.ShouldNotBe(key.EntityId, "an id belongs to one vault");
moved.Key.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"), "the material crossed intact");
vault.Hosts.Count.ShouldBe(2);
vault.Hosts.ShouldAllBe(host => host.Host.SshKeyId == moved.EntityId);
vault.Status.ShouldContain("Platform secrets");
vault.Status.ShouldContain("2 hosts");
}
/// <remarks>
/// The question this whole panel exists to ask. A binding resolves across vaults, so the moved host goes
/// on working for the person who moved it either way — and for the colleagues it has just joined, a host
/// whose key stayed behind is one they cannot connect with. Ticked, the key goes too and the host lands
/// naming it by the id it landed with.
/// </remarks>
[Fact]
public async Task MovingAHostWithItsKeyBrought_TakesTheKeyAcrossAndKeepsTheBinding()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasABindingToBring.ShouldBeTrue(vault.Status);
vault.BindingToBringQuestion.ShouldContain("deploy");
vault.BindingToBringNote.ShouldContain("Nothing else", Case.Insensitive);
vault.BringsTheBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
vault.BringsTheBindingAlong = true;
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var movedKey = vault.Keys.ShouldHaveSingleItem();
var movedHost = vault.Hosts.ShouldHaveSingleItem();
movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
vault.Status.ShouldContain("came with it");
vault.BringsTheBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
}
/// <remarks>
/// <para>
/// ◆ <b>The same question from the phone's action bar, which is that head's only route to it since the
/// connect card went.</b> It is asked in two shapes fewer than the desktop's: one host, because which key
/// to carry is a fact about one machine and a selection of six has six answers; and a move rather than a
/// copy, because taking the key out from under an original that is staying put would leave that original
/// unable to connect.
/// </para>
/// <para>
/// The three shapes are asserted in one test on purpose. What is being pinned is not that the box appears
/// but that it appears in exactly one of them — a rule that only reads as a rule when the other two are
/// beside it.
/// </para>
/// </remarks>
[Fact]
public async Task TheActionBarAsksAboutTheKey_ForOneHostAndForAMoveOnly()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
await AddHostBoundToKeyAsync(vault, "prod-web", key.EntityId);
var one = vault.Hosts.Single(
row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
vault.ChooseHostCommand.Execute(one);
vault.MoveChosenHostsToVaultCommand.Execute(null);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasAChosenBindingToBring.ShouldBeTrue(vault.Status);
vault.ChosenBindingToBringQuestion.ShouldContain("deploy");
vault.ChosenBindingToBringNote.ShouldContain("one other host", Case.Insensitive);
vault.BringsTheChosenBindingAlong.ShouldBeFalse("a disclosure is chosen, never defaulted into");
// A copy, which must never take the key: the original stays where it is and would be left bound to
// something its own vault no longer holds.
vault.CopyChosenHostsToVaultCommand.Execute(null);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasAChosenBindingToBring.ShouldBeFalse("a copy that moved the key would break the original");
// And two hosts, where the question has two answers and no tick can carry them.
vault.ToggleHostChoiceCommand.Execute(
vault.Hosts.Single(row => string.Equals(row.Label, "prod-web", StringComparison.Ordinal)));
vault.MoveChosenHostsToVaultCommand.Execute(null);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasAChosenBindingToBring.ShouldBeFalse("which key to carry is a fact about one machine");
}
/// <remarks>
/// Ticked, from the phone. The desktop's own path is measured above; what this adds is that the batch
/// command carries the key before it writes the host, so the host lands naming the id the key arrived
/// with rather than a tombstone.
/// </remarks>
[Fact]
public async Task MovingTheOneChosenHostWithItsKey_TakesTheKeyAcrossAndKeepsTheBinding()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.ChooseHostCommand.Execute(vault.Hosts.ShouldHaveSingleItem());
vault.MoveChosenHostsToVaultCommand.Execute(null);
vault.SelectedChosenHostVault =
vault.ChosenHostVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.BringsTheChosenBindingAlong = true;
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmSendChosenHostsToAVaultCommand.ExecuteAsync(null);
var movedKey = vault.Keys.ShouldHaveSingleItem();
var movedHost = vault.Hosts.ShouldHaveSingleItem();
movedKey.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.VaultId.ShouldBe(sharedVaultId, vault.Status);
movedHost.Host.SshKeyId.ShouldBe(movedKey.EntityId, "the binding follows the key's new id");
vault.Status.ShouldContain("came with it");
vault.BringsTheChosenBindingAlong.ShouldBeFalse("the tick does not survive the panel it was on");
vault.IsChoosingHosts.ShouldBeFalse();
}
/// <remarks>
/// The other answer, which is a real one: a key somebody does not want a team to hold stays where it is,
/// and the sentence afterwards says what that means for everybody else in the destination. It is also
/// what happens to anybody who presses MOVE without reading, which is why it is the unticked state.
/// </remarks>
[Fact]
public async Task MovingAHostWithoutItsKey_LeavesTheKeyBehindAndSaysWhatThatCosts()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
await AddHostBoundToKeyAsync(vault, "prod-db", key.EntityId);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem();
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
vault.Keys.ShouldHaveSingleItem().VaultId.ShouldBe(key.VaultId, "the key was not asked for");
var moved = vault.Hosts.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Host.SshKeyId.ShouldBe(key.EntityId, "the binding is kept — it resolves across vaults");
vault.Status.ShouldContain("another vault");
}
/// <remarks>
/// <para>
/// The case where a host stops connecting without naming anything. A group lends its default key to
/// everything filed under it, and a group belongs to the vault it is in — so the group stays behind, and
/// a host that only inherited its key used to arrive naming nothing at all.
/// </para>
/// <para>
/// The binding is written onto the host on the way across instead, which is the same key it
/// authenticated with before the move. The move is also asked about it: the tick box reads the resolved
/// binding, so an inherited key can be brought too.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAHostThatInheritsItsGroupsKey_WritesThatBindingOntoIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var key = await AddKeyAsync(vault, "deploy");
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "Production";
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices
.Single(choice => choice.EntityId == key.EntityId);
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "Production", StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
var before = vault.Hosts.ShouldHaveSingleItem();
before.Host.SshKeyId.ShouldBeNull("the host names nothing; the group lends it");
before.Authentication.ShouldBe("key");
vault.SelectedHost = before;
vault.MoveHostCommand.Execute(null);
vault.SelectedMoveVault =
vault.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.HasABindingToBring.ShouldBeTrue("an inherited key is still a key that can come along");
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveHostCommand.ExecuteAsync(null);
var moved = vault.Hosts.ShouldHaveSingleItem();
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Host.GroupId.ShouldBeNull("a group belongs to the vault the host came from");
moved.Host.SshKeyId.ShouldBe(key.EntityId, "what it inherited is written onto it");
moved.Authentication.ShouldBe("key", "it authenticates with what it did before the move");
}
/// <remarks>
/// <para>
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
/// on the form the host is being typed into, rather than through a standing preference on another
/// screen.
/// </para>
/// <para>
/// It is asserted from the editor's own selection rather than the keychain screen's, because the two
/// are deliberately separate — moving one must not move the other.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.NewHostCommand.Execute(null);
vault.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
var personal = vault.SelectedTargetVault!;
vault.EditorSelectedVault =
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts
.Single(host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal))
.VaultId
.ShouldBe(sharedVaultId);
vault.SelectedTargetVault.ShouldBe(
personal, "the editor's picker is the host's, not the screen's standing preference");
}
/// <remarks>
/// An existing host is not offered the picker at all. Moving an item between vaults is a delete and a
/// retype — they are encrypted under different keys — so a control that appeared to offer it would be
/// offering something no layer below can do.
/// </remarks>
[Fact]
public async Task EditingAnExistingHost_DoesNotOfferToMoveItBetweenVaults()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
await vault.LoadAsync(Token);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
vault.EditSelectedHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue(vault.Status);
vault.ShowsEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
}
/// <remarks>
/// <para>
/// A group is a shelf, and a shared vault is what makes it everybody's shelf. The assertions are the
/// three things that were missing while the group list was the active vault's alone: it is listed at
/// all, the row says which vault it is in, and a rename typed into it goes back to that vault rather
/// than forking a second group of the new name into the personal one.
/// </para>
/// <para>
/// Reloaded between the write and the read, so what is asserted is what came back out of the vault
/// rather than the row the save left behind.
/// </para>
/// </remarks>
[Fact]
public async Task AGroupFiledIntoASharedVault_IsListedThereAndRenamedThere()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.NewGroupCommand.Execute(null);
vault.ShowsGroupEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
var group = vault.Groups.ShouldHaveSingleItem();
group.VaultId.ShouldBe(sharedVaultId, vault.Status);
group.VaultBadge.ShouldBe("PLATFORM SECRETS", "a card in a session holding two vaults says which");
vault.EditGroupCommand.Execute(group);
vault.ShowsGroupEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
vault.DrawerSubtitle.ShouldBe(
"Platform secrets", "with no picker drawn, the header is what says whose shelf this is");
vault.GroupEditorLabel = "live";
await vault.SaveGroupCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
var renamed = vault.Groups.ShouldHaveSingleItem();
renamed.Label.ShouldBe("live");
renamed.VaultId.ShouldBe(sharedVaultId, "a rename must not fork a copy into the personal vault");
}
/// <remarks>
/// <para>
/// The group editor's picker is the group's, exactly as the host editor's is the host's: moving it must
/// not move the keychain screen's standing preference, and moving that one must not move a group
/// half-typed here.
/// </para>
/// <para>
/// The second half is the one worth the test. The picker is read when the form opens and the vault is
/// captured there, so a click on the other screen between typing the name and pressing ADD cannot
/// redirect the group somebody was making.
/// </para>
/// </remarks>
[Fact]
public async Task TheGroupEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var personal = vault.SelectedTargetVault!;
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "production";
// Moved back after the editor opened, the way a click on the keychain screen would. The group must
// still land in the shared vault.
vault.SelectedTargetVault = personal;
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.Groups.ShouldHaveSingleItem().VaultId.ShouldBe(sharedVaultId, vault.Status);
vault.SelectedTargetVault.ShouldBe(
personal, "the editor's picker is the group's, not the screen's standing preference");
}
/// <remarks>
/// A parent belongs to one vault, and a group filed under one in another vault would be a level half
/// the people holding the key cannot resolve — their hosts would inherit a port and a username from
/// nothing. The same rule the host editor's group picker follows, one level up the same tree.
/// </remarks>
[Fact]
public async Task AGroupsParentPicker_OffersOnlyTheVaultItIsGoingInto()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
// In the personal vault, which is where the standing preference points.
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "estate";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.Groups.ShouldHaveSingleItem().Label.ShouldBe("estate", vault.Status);
vault.NewGroupCommand.Execute(null);
vault.GroupEditorParentChoices
.Any(choice => string.Equals(choice.Label, "estate", StringComparison.Ordinal))
.ShouldBeTrue("a group in the personal vault may be filed under a personal group");
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorParentChoices.ShouldHaveSingleItem()
.EntityId.ShouldBeNull("only 'no parent' is left once the group is going somewhere else");
}
/// <remarks>
/// <para>
/// Moving the shelf rather than what is on it, which is the operation people were attempting one host at
/// a time: a group cannot go anywhere alone, because the machines filed under it and the groups nested
/// inside it are items of the vault it is leaving. All of them are re-sealed under the destination's key
/// and all of them take new ids, so what this asserts is not only that they arrived but that the tree
/// arrived — the child is still under the parent, and the host is still under the child, through two
/// levels of ids that were rewritten on the way across.
/// </para>
/// <para>
/// The parent the moved group was nested under is asserted <em>gone</em>, and that is the honest half. A
/// parent belongs to the vault it is in, so carrying the reference would leave everybody else in the
/// destination looking at a group hanging from nothing. It arrives at the top level and the sentence
/// says so.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAGroupToAnotherVault_TakesItsHostsAndItsNestedGroupsWithIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
await SeedNestedShelfAsync(vault);
var production = Named(vault, "production");
production.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
production.Group.ParentId.ShouldNotBeNull("it was nested, which is what has to stay behind");
// Passed directly, the way MoveGroupFromHeading resolves and hands in a row now that there are no
// group cards to select one from.
vault.MoveGroupCommand.Execute(production);
vault.IsMovingGroup.ShouldBeTrue(vault.Status);
vault.MoveGroupVaultChoices.ShouldNotContain(choice => choice.VaultId == production.VaultId);
vault.SelectedMoveGroupVault =
vault.MoveGroupVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// The pass that follows every write on this screen is made to fail, so that the move's own sentence
// is still on the status line to be read — the same arrangement, and for the same reason, as the
// host's move test above.
server.SyncFailure = new IOException("The server is not answering.");
await vault.ConfirmMoveGroupCommand.ExecuteAsync(null);
var moved = Named(vault, "production");
var nested = Named(vault, "web");
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.EntityId.ShouldNotBe(production.EntityId, "an id belongs to one vault");
moved.Group.ParentId.ShouldBeNull("a parent belongs to the vault the group came from");
nested.VaultId.ShouldBe(sharedVaultId, "a group inside it cannot be left in the other vault");
nested.Group.ParentId.ShouldBe(moved.EntityId, "and it is still nested under the group it was in");
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
host.VaultId.ShouldBe(sharedVaultId, "the hosts came with the shelf");
host.Host.GroupId.ShouldBe(nested.EntityId, "and are still filed where they were");
// The group it was nested under is the one thing that stayed, and it stayed where it was.
Named(vault, "estate").VaultId.ShouldBe(production.VaultId);
vault.Status.ShouldContain("Platform secrets");
vault.Status.ShouldContain("top level");
}
/// <remarks>
/// The move is refused where it would have nowhere to go, by the command rather than by an empty picker
/// — the same answer <c>MoveHostCommand</c> gives one level down, and the only place the question is
/// asked. The menu entry is drawn either way, because a menu whose items came and went would be a menu
/// whose items move.
/// </remarks>
[Fact]
public async Task MovingAGroupWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
{
await UnlockedAsync();
var vault = shell.Vault!;
await vault.LoadAsync(Token);
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.MoveGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
vault.IsMovingGroup.ShouldBeFalse();
vault.MoveGroupVaultChoices.ShouldBeEmpty();
vault.Status.ShouldContain("only vault you can write to");
}
/// <remarks>
/// <para>
/// The phone's route into the same move, and it is a different route rather than the same one reached
/// differently. That head's list draws group headings rather than cards, a heading is deliberately not
/// something it can select, and nothing there opens a group — so <c>GroupTarget</c> is null and a move
/// that only read it would leave the menu entry doing nothing at all. The panel is aimed by the heading
/// the menu was raised on instead.
/// </para>
/// <para>
/// The innermost shelf is the one moved, because it is the one with a machine on it and so the one with
/// a heading. That it arrives at the top level is the same rule the card's move follows: the group it
/// was nested under belongs to the vault it is leaving.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAGroupFromItsHeading_TakesItsHostsWithNothingSelected()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
await SeedNestedShelfAsync(vault);
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
row => string.Equals(row.Label, "web", StringComparison.Ordinal));
vault.GroupTarget.ShouldBeNull("the phone selects no card and opens no group");
vault.OpenGroupSheetCommand.Execute(heading);
vault.MoveGroupFromHeadingCommand.Execute(heading);
vault.GroupSheet.ShouldBeNull("the menu closes behind the entry that was pressed");
vault.IsMovingGroup.ShouldBeTrue(vault.Status);
vault.MovingGroupLabel.ShouldBe("web", "the panel names the shelf, having left the list behind");
vault.SelectedMoveGroupVault =
vault.MoveGroupVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
await vault.ConfirmMoveGroupCommand.ExecuteAsync(null);
var moved = Named(vault, "web");
moved.VaultId.ShouldBe(sharedVaultId, vault.Status);
moved.Group.ParentId.ShouldBeNull("a parent belongs to the vault the group came from");
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
host.VaultId.ShouldBe(sharedVaultId, "the machine came with the shelf");
host.Host.GroupId.ShouldBe(moved.EntityId);
}
/// <summary>The group card with a given name, re-found because every row is replaced on every reload.</summary>
private static HostGroupRowViewModel Named(VaultViewModel vault, string label) =>
vault.Groups.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <summary>
/// Builds estate production web in the personal vault, with prod-db on the innermost shelf.
/// </summary>
/// <remarks>
/// Three levels, because two would not tell a subtree that was walked from one that was assumed a single
/// level deep — the middle group is the one that has to arrive with a rewritten parent and a rewritten
/// child at once.
/// </remarks>
private static async Task SeedNestedShelfAsync(VaultViewModel vault)
{
await AddGroupAsync(vault, "estate", under: null);
await AddGroupAsync(vault, "production", under: "estate");
await AddGroupAsync(vault, "web", under: "production");
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "web", StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
}
/// <summary>Adds a group, optionally nested under one already there.</summary>
private static async Task AddGroupAsync(VaultViewModel vault, string label, string? under)
{
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = label;
if (under is not null)
{
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
choice => string.Equals(choice.Label, under, StringComparison.Ordinal));
}
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <remarks>
/// <para>
/// A group lives in exactly one vault, so + NEW HOST inside one has to open on that vault as well as on
/// that group. The two defaults were decided separately — the group from <see cref="VaultViewModel.GroupTarget"/>,
/// the vault from the standing "new items go to" preference — so pressing the button inside a shared
/// vault's group opened a form bound for the personal vault, with the group silently dropped by the
/// picker that keeps the two in step. <c>GroupFilter</c> is written directly here rather than through
/// <c>OpenGroupCommand</c>, which left with the group cards it used to open — see that property's own
/// remarks.
/// </para>
/// <para>
/// The second half is the same test read backwards, and it is what says which of the two wins. The
/// preference is an answer for a host being made from nowhere in particular; the group somebody is
/// standing inside is a better one, and it is the one the button was pressed in.
/// </para>
/// </remarks>
[Fact]
public async Task ANewHostInsideAGroup_IsMadeInThatGroupsVaultRatherThanTheStandingPreference()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
// In the personal vault, which is where a new group goes with nothing open and the preference
// untouched. It is the one the second half of this test stands in.
await AddGroupAsync(vault, "staging", under: null);
var personalVaultId = Named(vault, "staging").VaultId;
personalVaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with one vault");
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.GroupFilter = Named(vault, "production");
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault.ShouldNotBeNull(vault.Status).VaultId
.ShouldBe(sharedVaultId, "the group the screen is standing in is in the shared vault");
vault.EditorSelectedGroup.ShouldNotBeNull().Label
.ShouldBe("production", "and the group survives, having a vault it can be resolved in");
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
var host = vault.Hosts.ShouldHaveSingleItem();
host.VaultId.ShouldBe(sharedVaultId, vault.Status);
host.Host.GroupId.ShouldBe(Named(vault, "production").EntityId);
// And backwards: the preference names the shared vault and the open group is in the personal one.
vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupFilter = Named(vault, "staging");
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault.ShouldNotBeNull(vault.Status).VaultId
.ShouldBe(personalVaultId, "the group somebody is standing in beats the picker they set once");
vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("staging");
}
/// <remarks>
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
/// shared vault has to stay there, because hosts are read across every readable vault and so come back;
/// so does a group, since its list spans them too. Tags are not — the editable list is the active
/// vault's alone, like buckets — so a tag filed anywhere else would be created, pushed, reported as
/// added and then invisible, with nothing on the keychain screen able to rename or delete it and no
/// active-vault switcher to go and find it with.
/// </remarks>
[Fact]
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var sharedVaultId = vaults.SelectedVault!.VaultId;
var vault = shell.Vault!;
await vault.LoadAsync(Token);
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
vault.SelectedTargetVault = vault.TargetVaults.Single(
choice => choice.VaultId == sharedVaultId);
vault.NewTagCommand.Execute(null);
vault.TagEditorLabel = "eu-west-1";
await vault.SaveTagCommand.ExecuteAsync(null);
vault.Tags.ShouldHaveSingleItem().Label
.ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
}
/// <remarks>
/// The screen's answer to "who can actually open this". Asserted after somebody has been added
/// rather than before, because an empty list proves nothing about whether the call was made.
/// </remarks>
[Fact]
public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
// Two, and the creator is the other: their own self-grant is what makes a vault they just made
// readable at all, so a list that left it out would show the one person who can certainly open
// this vault as somebody who cannot.
vaults.Grants.Count.ShouldBe(2, vaults.Status);
var holder = vaults.Grants.Single(row => row.UserId == colleague);
holder.IsLive.ShouldBeTrue(vaults.Status);
holder.State.ShouldBe("holds a key");
}
/// <remarks>
/// A role change is authorization only. The status line has to say so, because the obvious reading
/// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
/// key they were already wrapped. Withdrawing that is a separate act.
/// </remarks>
[Fact]
public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
vaults.Status.ShouldContain("does not withdraw a vault key");
}
/// <remarks>
/// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
/// itself rather than letting the server do it: a button that produced a server error would be
/// reporting a rule the screen already knew.
/// </remarks>
[Fact]
public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
vaults.Status.ShouldContain("HAND OVER");
}
/// <remarks>
/// <para>
/// Both halves, because a transfer that only promoted the recipient would leave the vault owned
/// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
/// a single transaction to make impossible, so the client test asserts the same pair.
/// </para>
/// <para>
/// It also goes through the armed confirmation rather than calling the command directly, since
/// arming and confirming are where the target ids are carried — and carrying them on the selection
/// instead is how a confirmation ends up applied to whatever was clicked last.
/// </para>
/// </remarks>
[Fact]
public async Task HandingOverAVault_MakesThemTheOwnerAndTheCallerAnAdmin()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
vaults.HandOverCommand.Execute(null);
vaults.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
vaults.ShowsVaultActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
await vaults.ConfirmActionCommand.ExecuteAsync(null);
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
vaults.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
vaults.IsConfirming.ShouldBeFalse();
}
/// <remarks>
/// Renaming reaches the rest of the shell, which is the half a client can get wrong quietly: the name
/// is drawn on the badge of every host card in a session holding more than one vault, in the
/// file-this-into picker, and in the tab strip's menu.
/// </remarks>
[Fact]
public async Task RenamingAVault_ReachesTheKeychainScreensPickerToo()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vaultId = vaults.SelectedVault!.VaultId;
vaults.RenameVaultCommand.Execute(null);
vaults.EditVaultName = "Platform";
await vaults.SaveVaultNameCommand.ExecuteAsync(null);
vaults.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
vaults.Status.ShouldContain("re-encrypted");
await shell.Vault!.LoadAsync(Token);
shell.Vault.TargetVaults
.Single(choice => choice.VaultId == vaultId)
.Name
.ShouldBe("Platform");
}
/// <remarks>
/// v5c-2: the settings Vaults page draws one card per vault with no list selection to lean on, so
/// <c>RenameVaultRow</c>/<c>DeleteVaultRow</c> select the row first and then hand off to the commands
/// above — this proves the hand-off selects the right vault and reaches the same form.
/// </remarks>
[Fact]
public async Task RenameVaultRow_SelectsTheCardThenOpensTheSameFormRenameVaultDoes()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
// A different vault selected first, so the row argument is what actually decides which one the
// form is about rather than whatever was already selected.
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.RenameVaultRowCommand.Execute(shared);
vaults.SelectedVault.ShouldBe(shared);
vaults.IsRenamingVault.ShouldBeTrue();
vaults.EditVaultName.ShouldBe("Platform secrets");
}
[Fact]
public async Task DeleteVaultRow_SelectsTheCardThenArmsTheSameConfirmationDeleteVaultDoes()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.DeleteVaultRowCommand.Execute(shared);
vaults.SelectedVault.ShouldBe(shared);
vaults.IsConfirming.ShouldBeTrue();
vaults.PendingAction!.Question.ShouldContain("Platform secrets");
}
/// <remarks>
/// The settings page's members panel: pressing the card's members icon on a vault that is not already
/// selected has to select it first, or the panel would open over whichever vault the list last landed
/// on rather than the one that was actually clicked.
/// </remarks>
[Fact]
public async Task OpenMembersPanel_SelectsTheVaultItWasOpenedForAndReadsItsMembers()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.IsMembersPanelOpen.ShouldBeFalse();
vaults.OpenMembersPanelCommand.Execute(shared);
vaults.IsMembersPanelOpen.ShouldBeTrue();
vaults.SelectedVault.ShouldBe(shared);
// OpenMembersPanel's own selection assignment starts a read nothing here can await — see
// OnSelectedVaultChanged — so this reads it again through LoadAsync, which is awaited, rather than
// racing the fire-and-forget one.
await vaults.LoadAsync(Token);
vaults.Members.ShouldContain(member => member.IsSelf);
vaults.CloseMembersPanelCommand.Execute(null);
vaults.IsMembersPanelOpen.ShouldBeFalse();
}
/// <remarks>
/// <para>
/// <b>An address with no account is a refusal, and the sentence has to say what to do about it.</b>
/// This used to issue an invitation from the same button — a standing instruction that the next
/// account signing in with that address joined the vault. It does not any more: an address is not a
/// way in, and only an account somebody named can be added.
/// </para>
/// <para>
/// The status assertion is the point of the test. "No such account" on its own is a dead end that
/// reads as a typo, so what is pinned is that the message names the address and says the remedy —
/// they sign in here once — and that nothing is held for them in the meantime.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_IsRefusedAndSaysWhatHasToHappenFirst()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
vaults.NewMemberEmail = "newcomer@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.ShouldHaveSingleItem("nobody joined — there was nobody to add");
vaults.Status.ShouldContain("newcomer@example.com");
vaults.Status.ShouldContain("sign in here once");
vaults.Status.ShouldContain("Nothing is held for them");
}
/// <remarks>
/// <para>
/// The regression this whole path was rewritten for. An account exists from its owner's first
/// authenticated request and publishes no key until they choose a passphrase on their own machine,
/// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD refused
/// somebody who was standing right there.
/// </para>
/// <para>
/// So the assertion is that they are a member, and that the row says what is true of them — no key,
/// so nothing can be shared with them yet.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
{
await UnlockedAsync();
var vaults = shell.Vaults;
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
await CreateVaultAsync(vaults, "Platform secrets");
vaults.NewMemberEmail = "carol@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.Count.ShouldBe(2, vaults.Status);
var member = vaults.Members.Single(row => row.UserId == colleague);
member.Email.ShouldBe("carol@example.com");
// The label the user asked to see, and the reason SHARE KEY is not the next step.
member.KeyState.ShouldContain("no key yet");
vaults.Status.ShouldContain("Added");
vaults.Status.ShouldContain("no key yet");
}
/// <remarks>
/// The refusal does not clear the box, and that is the half worth pinning separately. A message
/// telling somebody to come back once that person has signed in is a message they act on later — with
/// the address gone they would have to find it again, and the natural reading of an emptied box is
/// that the add went through.
/// </remarks>
[Fact]
public async Task AnAddressThatWasRefused_IsStillInTheBox()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
vaults.NewMemberEmail = "stranger@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.NewMemberEmail.ShouldBe("stranger@example.com");
}
/// <remarks>
/// <para>
/// A reload rebuilds the vault list and reselects, so a reload that changed the selection — creating
/// the first shared vault is exactly that — used to leave two reads of the same membership list in
/// flight: the one the reload awaits, and one the selection handler started on its own. Both clear the
/// member list and then both append to it, so every member was drawn twice. On a vault nobody has been
/// added to yet, whose only member is its owner, that read as the owner being in it twice.
/// </para>
/// <para>
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
/// memory each read finishes before the next begins, so the duplicate never appears and the bug
/// survives the test. Holding the read open is what makes this behave like a server.
/// </para>
/// </remarks>
[Fact]
public async Task CreatingAVault_ReadsItsMembersOnce()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
var gate = new TaskCompletionSource();
server.MemberReadGate = gate;
var create = vaults.CreateVaultCommand.ExecuteAsync(null);
// Asserted while the read is still in flight: that is the only moment at which a second read
// started by the selection handler is distinguishable from the reload's own.
server.MemberReads.ShouldBe(1, "a reload reads the selected vault's members once");
gate.SetResult();
await create;
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
/// <remarks>
/// Through the form rather than straight at the command, because the name is what the form is for —
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
/// </remarks>
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
private static string PrivateKey(string body) =>
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
/// <summary>Puts one key in whatever vault the keychain is filing into, and hands back its row.</summary>
private static async Task<SshKeyRowViewModel> AddKeyAsync(VaultViewModel vault, string label)
{
vault.NewKeyCommand.Execute(null);
vault.KeyEditorLabel = label;
vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.IsEditingKey.ShouldBeFalse(vault.Status);
return vault.Keys.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
}
/// <summary>Creates a host that authenticates with one key, by choosing it in the editor.</summary>
private static async Task AddHostBoundToKeyAsync(VaultViewModel vault, string label, Guid keyId)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.EntityId == keyId);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse(vault.Status);
}
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
{
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = name;
await vaults.CreateVaultCommand.ExecuteAsync(null);
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
}
/// <remarks>
/// <para>
/// Sharing a snippet, which is a move like a host's and simpler in exactly one way: a snippet crosses
/// whole. It has no group, no tags and no key binding — nothing on it points at an item of the vault it
/// came from — so the assertion the host's move makes about what was left behind has no analogue, and
/// the one worth making instead is that <em>nothing</em> was lost, the flag that decides whether it
/// presses Enter for you least of all.
/// </para>
/// <para>
/// The new id is asserted for the reason the host's test gives: one entity id in two vaults would make
/// the destination's row and the source's tombstone the same row.
/// </para>
/// </remarks>
[Fact]
public async Task MovingASnippetToAnotherVault_ReSealsItThereAndCarriesItWhole()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var snippets = SnippetsOver(vault);
await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: true);
var before = Snippet(snippets, "restart the api");
before.VaultId.ShouldNotBe(sharedVaultId);
snippets.Selected = before;
snippets.CanMove.ShouldBeTrue("there is a second vault this session can write to");
snippets.MoveCommand.Execute(null);
snippets.IsMoving.ShouldBeTrue(snippets.Status);
snippets.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);
snippets.SelectedMoveVault =
snippets.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
// The pass that follows every write is made to fail, so the move's own sentence is still on the
// status line to be read. See the host's move test, which does this for the same reason.
server.SyncFailure = new IOException("The server is not answering.");
await snippets.ConfirmMoveCommand.ExecuteAsync(null);
var after = Snippet(snippets, "restart the api");
after.VaultId.ShouldBe(sharedVaultId, vault.Status);
after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
after.Snippet.Command.ShouldBe("sudo systemctl restart dodossh-api");
after.Snippet.RunsOnInsert.ShouldBeTrue("the flag that decides whether it presses Enter came too");
snippets.Selected?.EntityId.ShouldBe(after.EntityId, "the pane follows the snippet it moved");
snippets.Status.ShouldContain("Platform secrets");
}
/// <remarks>
/// Refused by the command rather than by an empty picker, and the phone reads the same question to
/// decide whether to draw the button at all.
/// </remarks>
[Fact]
public async Task MovingASnippetWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
{
await UnlockedAsync();
var vault = shell.Vault!;
await vault.LoadAsync(Token);
var snippets = SnippetsOver(vault);
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
snippets.Selected = Snippet(snippets, "uptime");
snippets.CanMove.ShouldBeFalse("the personal vault is the only one there is");
snippets.MoveCommand.Execute(null);
snippets.IsMoving.ShouldBeFalse();
snippets.MoveVaultChoices.ShouldBeEmpty();
snippets.Status.ShouldContain("only vault you can write to");
}
/// <remarks>
/// The picker the snippet editor grew, and the thing it is for: choosing at the moment a snippet is
/// written, on the form it is being typed into. A command is worth sharing precisely when somebody else
/// would otherwise be retyping it, so filing it into the team's vault at that moment is the ordinary
/// case rather than an afterthought.
/// </remarks>
[Fact]
public async Task TheSnippetEditorFilesANewSnippetIntoTheVaultChosenOnIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var snippets = SnippetsOver(vault);
snippets.NewCommand.Execute(null);
snippets.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
snippets.EditorSelectedVault =
snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
snippets.EditorLabel = "rotate the certs";
snippets.EditorCommand = "sudo certbot renew";
await snippets.SaveCommand.ExecuteAsync(null);
Snippet(snippets, "rotate the certs").VaultId.ShouldBe(sharedVaultId, snippets.Status);
}
/// <remarks>
/// <para>
/// The bug the per-editor latch exists to prevent, and the reason the screen could not simply keep
/// writing to the active vault once its list spanned several. An update sent to the active vault would
/// create a second snippet there and leave the team's original untouched: a fork that shows up only
/// when a colleague asks why the correction never arrived.
/// </para>
/// <para>
/// The count is the assertion. One snippet with that label, in the vault it started in.
/// </para>
/// </remarks>
[Fact]
public async Task EditingASharedSnippet_WritesBackToItsOwnVaultRatherThanForkingACopy()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
var snippets = SnippetsOver(vault);
snippets.NewCommand.Execute(null);
snippets.EditorSelectedVault =
snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
snippets.EditorLabel = "drain the node";
snippets.EditorCommand = "kubectl drain node-1";
await snippets.SaveCommand.ExecuteAsync(null);
snippets.Selected = Snippet(snippets, "drain the node");
snippets.EditCommand.Execute(null);
snippets.ShowsEditorVaultChoice.ShouldBeFalse("an existing snippet's vault is not a field of the form");
snippets.EditorCommand = "kubectl drain node-1 --ignore-daemonsets";
await snippets.SaveCommand.ExecuteAsync(null);
var edited = Snippet(snippets, "drain the node");
edited.VaultId.ShouldBe(sharedVaultId, "the edit went back to the vault it came from");
edited.Snippet.Command.ShouldBe("kubectl drain node-1 --ignore-daemonsets");
}
/// <summary>The snippet with a given name, re-found because every row is replaced on every reload.</summary>
private static SnippetRowViewModel Snippet(SnippetsViewModel snippets, string label) =>
snippets.Visible.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <summary>The snippets screen over a vault, with no terminal to insert into.</summary>
/// <remarks>
/// Insert is not what this suite is about — see <c>ShellFlowTests</c> for that — so the target is empty
/// and the delivery is a stub that would report success if anything asked it to.
/// </remarks>
private static SnippetsViewModel SnippetsOver(VaultViewModel vault) =>
new(vault, () => InsertTarget.None, (_, _, _, _) => Task.FromResult(true));
private static async Task AddSnippetAsync(
SnippetsViewModel snippets,
string label,
string command,
bool runs)
{
snippets.NewCommand.Execute(null);
snippets.EditorLabel = label;
snippets.EditorCommand = command;
snippets.EditorRunsOnInsert = runs;
await snippets.SaveCommand.ExecuteAsync(null);
}
/// <remarks>
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
/// that entry is what the client verifies its own directory answer against.
/// </remarks>
private async Task UnlockedAsync()
{
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.Passphrase = Passphrase;
shell.ConfirmPassphrase = Passphrase;
await shell.EnrollCommand.ExecuteAsync(null);
shell.RecoveryCodeWrittenDown = true;
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
}