Public Access
Merge branch 'main'
Two of main's changes land in files this branch rewrote, and both needed carrying across by hand rather than by the merge. The phone's nav staying up on Connections with nothing running is a fourth input to RefreshChrome, which this branch had already given two more — whether hosts are ticked and whether the host editor is filling the screen. They compose: the rail and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped terminal surface keeps its way off the screen and the editor still takes the whole display. The key question under the host's move panel is the harder one, because this branch deleted the panel it was added to. The connect card is gone and the phone's only route to a move is the action bar, so leaving the merge to take this side would have removed a capability main had just shipped — silently, since nothing would fail to build. It is asked in the action bar's own picker instead, 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. BindingOfTheMovingHost splits into MovableBindingOf so both heads answer it the same way from different panels. Main also fixed a real trap in the same commit — a host that only inherited its key from its group arrived in the destination naming nothing at all, because the group stays behind — and the batch move had the same bug for the same reason. It goes through Detached now, which is where that fix lives. The carried host is written as the carry left it rather than being detached again, which is the one thing worth measuring: the key takes a new id over there, so a run that rebuilt the payload from the row would send the machine across naming a tombstone. Both directions are pinned, along with the rule about which shapes the question is asked in at all.
This commit is contained in:
@@ -785,6 +785,351 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
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,
|
||||
@@ -1599,6 +1944,41 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
/// 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);
|
||||
@@ -1613,6 +1993,210 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user