Let a host be moved to another vault

The one thing the host editor's vault picker has always been unable to offer,
and the comment beside it said so: an existing host's vault was not a field
because the two vaults are encrypted under different keys. That is still true.
What changed is that it is no longer a reason to have nothing.

**A move is a copy and a tombstone, and it cannot be anything else.** A payload
is sealed under its vault's key and its AAD binds the vault, the entity id and
the item version, so no edit moves one and no server call could — the server
holds ciphertext it cannot read. What crosses is the plaintext, in this process,
between an unwrap under one key and a seal under another. VaultItemRepository
gained MoveAsync for it, so the three decisions below live in one place with
their reasons rather than being re-derived at each call site.

The item takes a new id. Keeping it would put one entity id in two vaults, and
the item table is keyed on the type and the id rather than on the vault — so the
destination's row and the source's tombstone would be the same row, and the move
would delete what it had just written.

The write comes first and the tombstone second, which decides what an
interruption leaves: a copy in both vaults, visible and deletable, rather than a
tombstone with nothing on the other side. Both are queued rather than sent, so
the window is a crash between two local writes; it is still worth being on the
survivable side of.

Two activity lines rather than one, because that is what the two vaults actually
record. A single "moved" line would have to be written to one of them and would
be missing from the other's history.

**The group and the tags stay behind, and that is the half that makes this
honest.** Both are items of the vault the host is leaving: the editor's group
picker offers one vault's groups and the chips are drawn from one vault's tags.
A host carrying either across would resolve it on the machine that moved it —
groups and tags 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. Cleared and reported beats carried and invisible.

The key or password binding is kept, and the difference is not inconsistency.
Those genuinely resolve across vaults — one key on twenty hosts in three vaults
is the arrangement they exist for — so clearing them would take a working host
and make one that cannot connect. What the message does instead is name a
binding that is now outside the destination, because that is precisely what the
other members of it will not be able to resolve.

**It is not in the editor**, on either head: the desktop puts it in the detail
pane's ⋯ menu above the separator Delete sits below, and the phone beside EDIT.
A picker inside the form would move a machine as a side effect of correcting a
port, which is the bug the editor's own vault picker was fenced off to prevent in
the first place. The panel takes the footer as the deletion question does, and
says what will be left behind before the tap rather than after it — on a phone,
where the status line afterwards is one line on a screen somebody has already
navigated away from, that is the only place it reliably gets read.

The phone hides the button where there is nowhere to go rather than offering one
that answers with a refusal; the desktop keeps its menu entry either way, because
a menu that grew and shrank would be a menu whose items move.

One thing found while writing the test and deliberately not changed. The pass
that follows every write on this screen reports what it moved and supersedes the
confirmation — for a save and a delete as much as for a move — so the move's own
sentence is what somebody sees offline. The test asserts it in that state and
says why. Making confirmations survive their own sync pass is a question about
the whole screen rather than about this.

Four places said an item could never be moved, two of them sentences on screen in
both heads. All four now say what is true, including the design gaps document,
where the chevron beside the vault name stays undrawn for a different reason: a
chevron on a subtitle implies an edit, and this is a re-seal, a new id and two
references left behind.
This commit is contained in:
2026-08-04 16:04:15 +02:00
parent e9cea2ccbc
commit bee6202949
9 changed files with 634 additions and 70 deletions
@@ -223,6 +223,36 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
/// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
/// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
/// </para>
/// <para>
/// The state is set here rather than through <c>MoveHostCommand</c>, which would refuse: this fixture's
/// account holds one vault, and the command declines rather than open a picker with nothing in it. What
/// this test is about is the rectangle, and the flow that fills it is covered in
/// <c>DodoSSH.Client.App.Tests</c>.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostDrawerFitsWithTheMovePanelOpen()
{
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.MoveVaultChoices.Add(
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
vault.SelectedMoveVault = vault.MoveVaultChoices[0];
vault.IsMovingHost = true;
vault.ShowsHostPaneActions.ShouldBeFalse("the panel takes the footer rather than sharing it");
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
@@ -532,6 +532,121 @@ public sealed class VaultSharingTests : IAsyncLifetime
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>
/// 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 picker the host editor grew, and the thing it is for: choosing at the moment a host is created,