Public Access
+ NEW HOST decided two defaults separately and let them contradict each other. The group came from the screen — the selected card, or failing that the group whose contents are showing — and the vault came from the keychain screen's standing "new items go to" preference. Inside a group belonging to any other vault the two disagreed, and the group is what lost: GroupInEditingVault drops a group the editor's vault has not got, on the sound reasoning that a host filed under an id its readers cannot resolve looks unfiled to everybody but the person who wrote it. So pressing the button while standing inside a team's PLATFORM opened a form filed under nothing, bound for the personal vault, with no sentence anywhere saying either thing had happened. The vault now follows the group. A group lives in exactly one vault, so a host that is to land in that group has to be sealed in that vault too — which is the rule + NEW GROUP has followed for a parent since the cards became a tree, and the comment there claiming this as a deliberate difference from the host's editor is the one the code has now caught up with. The filter stays, because there is one case left for it: the group's vault may be one this session can read and not write, a team vault this account is a viewer of. TargetVaults is the readable-and-writable set and is what decides here, so a viewer keeps the standing preference and loses the group with it, rather than opening an editor aimed at a save that cannot happen. Both directions are tested, since one alone would not say which default wins: standing in a shared vault's group, the editor opens on that vault with the group selected and the host saves there; and with the preference pointed at the shared vault while a personal-vault group is open, the group beats the picker somebody set once.
2297 lines
92 KiB
C#
2297 lines
92 KiB
C#
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>
|
||
/// 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.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");
|
||
}
|
||
|
||
/// <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");
|
||
|
||
// 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");
|
||
}
|
||
|
||
/// <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.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
|
||
|
||
vault.MoveGroupCommand.Execute(null);
|
||
|
||
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>
|
||
/// 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.
|
||
/// </para>
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
[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");
|
||
}
|
||
|
||
/// <remarks>
|
||
/// <para>
|
||
/// 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.
|
||
/// </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.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");
|
||
}
|
||
|
||
/// <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>
|
||
/// <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);
|
||
}
|
||
}
|