Public Access
Give the hosts board its vaults, and the Hetzner import a vault and a key
Six things, all of them answers to a question a screen was deciding on somebody's behalf. The board draws a heading per vault and sorts each vault's hosts by that vault's own groups. A group is an item of one vault, so drawing every group of every vault in one run flattened away containment that already exists: two vaults each holding a "production" were two headings with the same word on them, told apart by a small grey name beside each. Single- vault sessions are unchanged. There is now one "No group" heading per vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts folding every other vault's away with them. A Ctrl-click starts its set with the card the board has lit. The board always has a selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card lit, nothing ticked" is the state this screen opens in, and ticking a second card there produced a set of one: the machine the user was looking at was left out of the count and out of everything Delete, Move and Change group then ran over. The card tooltip says the host's name as well as its address. 214 pixels of card trims the name, and the tooltip was the one place it was not said in full. The card's floor drops from 120 to 104. A card with no tags measures about 87, so the mock's number was thirty pixels of air under every card on a board that is scrolled. "Move to another vault…" joins the board's context menu for one host. Moving one machine meant opening its pane and finding the menu inside it, while moving six was a right click away — the one-host case was the harder of the two. And the Hetzner import asks which vault and which key. A project belongs to a team more often than to a person, so forty servers landing wherever the session happens to be standing is the wrong default; ImportHostsAsync takes the target vault, and the ssh_config importer passes null and is unchanged. The key is the half Hetzner cannot supply and the keychain usually already holds — the public half went to Hetzner when the servers were made — so the import points every host at it rather than bringing one. It defaults to no key rather than guessing, and says so when the key it was given lives in a different vault from the hosts.
This commit is contained in:
1 parent
ed936380b3
commit
c859705b83
9 files changed
+926
-84
No files matched your search
@@ -250,12 +250,19 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
/// way down rather than acted on as it bubbles.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>And the set the first Ctrl-click starts has the lit card in it.</b> The board always has a
|
||||
/// selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card
|
||||
/// lit, nothing ticked" is the state this screen opens in. A Ctrl-click there used to produce a set of
|
||||
/// one, leaving the machine the user was looking at out of the count and out of everything Delete, Move
|
||||
/// and Change group then acted on. See <c>VaultViewModel.ExtendHostChoice</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
|
||||
/// card is in play and every command on this screen is about the same host.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
|
||||
public async Task CtrlClickingCardsTicksThemAndTheLitCardWithoutMovingTheSelection()
|
||||
{
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
@@ -267,16 +274,19 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
|
||||
Click(CardFor(screen, second), window, RawInputModifiers.Control);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(1);
|
||||
vault.ChosenHostCount.ShouldBe(2, "the card the board had lit is in the set the Ctrl-click began");
|
||||
first.IsChosen.ShouldBeTrue("which is the half that used to be left out of it");
|
||||
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
|
||||
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
||||
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
|
||||
|
||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||
vault.ChosenHostCount.ShouldBe(2);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
|
||||
first.IsChosen.ShouldBeFalse("the lit card is not folded back in over a set that already exists");
|
||||
|
||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
|
||||
vault.ChosenHostCount.ShouldBe(2);
|
||||
|
||||
Click(CardFor(screen, first), window);
|
||||
|
||||
@@ -387,6 +397,12 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
|
||||
var tickedCard = CardFor(screen, ticked);
|
||||
|
||||
// Lit first, and it matters: a Ctrl-click folds the lit card into the set it starts — see
|
||||
// ExtendHostChoice — so ticking one card while another is lit would leave no card outside the
|
||||
// set for the second half of this test to right-click.
|
||||
vault.SelectedHost = ticked;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
Click(tickedCard, window, RawInputModifiers.Control);
|
||||
|
||||
RightClick(tickedCard, window);
|
||||
|
||||
@@ -405,6 +405,84 @@ public sealed partial class ShellFlowTests
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>A server carrying enough to be imported, used wherever the shape is not what is being tested.</summary>
|
||||
/// <summary>
|
||||
/// The two answers this import used to decide on somebody's behalf: which vault, and which key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A Hetzner project is the case where "wherever I happen to be standing" is the wrong vault.</b>
|
||||
/// The <c>ssh_config</c> importer reads this machine's own account and files into the active vault
|
||||
/// fairly; a project belongs to a team more often than to a person, and forty servers landing in
|
||||
/// somebody's personal vault are either forty items nobody else can see or forty moved one at a time.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The key is the half Hetzner cannot supply and the keychain usually already holds.</b> The public
|
||||
/// half went to Hetzner when the servers were made; the private half never did. So the import points at
|
||||
/// a key rather than bringing one — the host stores its id, exactly as one bound by hand does, and the
|
||||
/// material is neither read nor copied. Without it this screen's honest answer was forty hosts asking
|
||||
/// for a password.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The key here is deliberately left in the personal vault while the hosts go to the shared one, which
|
||||
/// is allowed and is the one thing about it worth saying out loud: a binding resolves across every vault
|
||||
/// <em>this</em> account can read, so these hosts connect here and not for a colleague who holds only
|
||||
/// the destination's key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ImportingIntoAChosenVault_BindsEveryServerToTheChosenKey()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.NewVaultCommand.Execute(null);
|
||||
vaults.NewVaultName = "Platform secrets";
|
||||
|
||||
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
|
||||
var keyId = vault.Keys.ShouldHaveSingleItem().EntityId;
|
||||
|
||||
var wizard = Wizard(Project(WebServer));
|
||||
|
||||
wizard.Token = HetznerToken;
|
||||
|
||||
await wizard.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
wizard.HasVaultChoice.ShouldBeTrue("there are two vaults to choose between");
|
||||
wizard.HasKeyChoice.ShouldBeTrue("the keychain has a key to point at");
|
||||
wizard.SelectedKey.ShouldNotBeNull().KeyId.ShouldBeNull("binding a key is chosen, never defaulted");
|
||||
|
||||
wizard.SelectedVault = wizard.VaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
wizard.SelectedKey = wizard.KeyChoices.Single(choice => choice.KeyId == keyId);
|
||||
|
||||
wizard.AuthenticationNote.ShouldContain("deploy");
|
||||
wizard.AuthenticationNote.ShouldContain(
|
||||
"another vault",
|
||||
Case.Sensitive,
|
||||
"the key stayed behind, and the colleagues these hosts just joined cannot resolve it");
|
||||
|
||||
await wizard.ImportCommand.ExecuteAsync(null);
|
||||
|
||||
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
||||
|
||||
imported.VaultId.ShouldBe(sharedVaultId, wizard.Status);
|
||||
imported.Host.SshKeyId.ShouldBe(keyId, "pointed at the key rather than carrying one");
|
||||
wizard.Status.ShouldContain("Platform secrets");
|
||||
|
||||
// Through the ordinary path, as every other write here is: it reached the server without anything
|
||||
// pressing Sync.
|
||||
server.LiveRowCount.ShouldBe(2, "the key and the host");
|
||||
}
|
||||
|
||||
private const string WebServer = """
|
||||
{
|
||||
"id": 2,
|
||||
|
||||
@@ -6485,6 +6485,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
Key: null,
|
||||
KeyPath: null),
|
||||
],
|
||||
vaultId: null,
|
||||
Token);
|
||||
|
||||
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
|
||||
@@ -7022,6 +7023,60 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The desktop's Ctrl-click, and the card the board already had lit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The board always has a selection, so "one card lit, nothing ticked" is the state this screen
|
||||
/// opens in</b> — a fresh unlock lights the first host so that CONNECT has something under it, see
|
||||
/// <c>VaultViewModel.SelectionAfterReload</c>. A Ctrl-click there used to start a set of one, leaving
|
||||
/// the machine the user was looking at out of the count and out of everything Delete, Move to another
|
||||
/// vault and Change group then ran over. It is the omission somebody could act on without noticing:
|
||||
/// two cards are marked on screen and the bar says one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The fold happens only into an empty set and never onto the card being clicked. The first is what
|
||||
/// makes it a starting point rather than a card that keeps re-adding itself; the second is what keeps
|
||||
/// every Ctrl-click visibly doing something.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ExtendingTheChoice_StartsTheSetWithTheCardTheBoardHasLit()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "staging");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.IsChoosingHosts.ShouldBeFalse();
|
||||
|
||||
vault.ExtendHostChoiceCommand.Execute(Host(vault, "staging"));
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(2, "the lit card is in the set the Ctrl-click began");
|
||||
Host(vault, "prod-db").IsChosen.ShouldBeTrue("which is the half that used to be left out");
|
||||
Host(vault, "staging").IsChosen.ShouldBeTrue();
|
||||
vault.SelectedHost.ShouldBeSameAs(Host(vault, "prod-db"), "ticking a card is not selecting it");
|
||||
|
||||
vault.ExtendHostChoiceCommand.Execute(Host(vault, "prod-db"));
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(1, "a set that already exists is not folded into again");
|
||||
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
|
||||
|
||||
vault.ExtendHostChoiceCommand.Execute(Host(vault, "staging"));
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("emptying the set leaves the mode, as a tap does");
|
||||
|
||||
// The lit card itself, from empty: it is ticked rather than folded in and toggled straight back out,
|
||||
// which would be a gesture with no effect and no way to tell it had been heard.
|
||||
vault.ExtendHostChoiceCommand.Execute(Host(vault, "prod-db"));
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(1);
|
||||
Host(vault, "prod-db").IsChosen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
|
||||
/// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
|
||||
@@ -7237,6 +7292,97 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The board's outer level, which is the vault, and its inner one, which is the group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A group belongs to exactly one vault, and the board used to flatten that away.</b> Every group
|
||||
/// of every readable vault was drawn in one run with a small grey vault name beside each heading, so two
|
||||
/// vaults each holding a "Production" were two headings with the same word on them, told apart only by
|
||||
/// reading the second line. Vaults are the headings now and groups sort the hosts inside one — see
|
||||
/// <c>HostSectionViewModel.VaultHeading</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The fold state is the half worth a test rather than a comment. There is one "No group" heading per
|
||||
/// vault now, and every one of them names no group at all — keyed on <c>Guid.Empty</c> as they were,
|
||||
/// folding one vault's unfiled hosts away folded every other vault's with it. See
|
||||
/// <c>SidebarGroupHeader.CollapseKey</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostBoard_PutsEachVaultsGroupsUnderItsOwnHeading()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.NewVaultCommand.Execute(null);
|
||||
vaults.NewVaultName = "Platform secrets";
|
||||
|
||||
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
// The same group name in both vaults, which is the case the old board could not tell apart.
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = "Production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorSelectedVault =
|
||||
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
vault.GroupEditorLabel = "Production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
var sharedGroupId = vault.Groups.Single(group => group.VaultId == sharedVaultId).EntityId;
|
||||
|
||||
// One unfiled host in each vault, and one filed under the shared vault's own group.
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostInAsync(vault, sharedVaultId, "stage-web", groupId: null);
|
||||
await AddHostInAsync(vault, sharedVaultId, "prod-web", sharedGroupId);
|
||||
|
||||
var sections = vault.HostSections.ToList();
|
||||
var headings = sections.Where(section => section.StartsAVault).ToList();
|
||||
|
||||
headings.Count.ShouldBe(2, "one heading per vault, on the first section of each");
|
||||
headings[1].VaultHeading.ShouldBe("PLATFORM SECRETS");
|
||||
|
||||
var opensTheShared = sections.IndexOf(headings[1]);
|
||||
|
||||
opensTheShared.ShouldBeGreaterThan(0, "the vault this session is standing in comes first");
|
||||
|
||||
sections.Take(opensTheShared).SelectMany(section => section.Hosts)
|
||||
.ShouldAllBe(row => row.VaultId != sharedVaultId);
|
||||
sections.Skip(opensTheShared).SelectMany(section => section.Hosts)
|
||||
.ShouldAllBe(row => row.VaultId == sharedVaultId);
|
||||
|
||||
sections.Count(section => string.Equals(section.Header?.Label, "Production", StringComparison.Ordinal))
|
||||
.ShouldBe(2, "each vault draws its own, rather than one heading for both");
|
||||
|
||||
sections.Where(section => section.Header is not null)
|
||||
.ShouldAllBe(section => section.Header!.VaultBadge.Length == 0,
|
||||
"the vault is named on the row above rather than beside every heading under it");
|
||||
|
||||
// ── The fold, which is per vault because the heading is ──
|
||||
var unfiled = sections.Where(section => section.Header is { GroupId: null }).ToList();
|
||||
|
||||
unfiled.Count.ShouldBe(2, "each vault has unfiled hosts of its own");
|
||||
|
||||
vault.ToggleGroupCommand.Execute(unfiled[0].Header);
|
||||
|
||||
var folded = vault.HostSections.Where(section => section.Header is { GroupId: null }).ToList();
|
||||
|
||||
folded[0].Header!.IsExpanded.ShouldBeFalse();
|
||||
folded[0].Hosts.ShouldBeEmpty("a folded section draws its heading and no cards");
|
||||
folded[1].Header!.IsExpanded.ShouldBeTrue("one vault's unfiled hosts fold on their own");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
|
||||
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
|
||||
@@ -10112,6 +10258,33 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
|
||||
/// <summary>Adds a host to a named vault, optionally filed under one of that vault's groups.</summary>
|
||||
/// <remarks>
|
||||
/// The editor's own vault picker rather than the keychain screen's standing preference, which is the
|
||||
/// order the two are meant to be used in: where a host goes is a field of the host in front of you. The
|
||||
/// vault is set first because the group choices are that vault's — see
|
||||
/// <c>VaultViewModel.EditorGroupChoices</c>.
|
||||
/// </remarks>
|
||||
private static async Task AddHostInAsync(
|
||||
VaultViewModel vault,
|
||||
Guid vaultId,
|
||||
string label,
|
||||
Guid? groupId)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedVault =
|
||||
vault.EditorVaultChoices.Single(choice => choice.VaultId == vaultId);
|
||||
vault.EditorSelectedGroup =
|
||||
vault.EditorGroupChoices.Single(choice => choice.EntityId == groupId);
|
||||
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private static async Task AddHostAsync(VaultViewModel vault, string label)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
Reference in new issue
Block a user