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
+10 -4
View File
@@ -342,10 +342,16 @@ Six limits, stated rather than discovered:
into it that is not there. into it that is not there.
Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's
editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two editor has its own picker**, beside the name, because it is the decision on that form that decides who can
vaults are encrypted under different keys, so moving an item afterwards means deleting it and typing it read the host. It is absent when you edit an existing one rather than present and refusing, and that is not
again — and the picker is therefore absent when you edit an existing host rather than present and refusing. because the host is stuck: **a host can be moved to another vault** — "Move to another vault…" in the detail
Keys, passwords and buckets take theirs from a standing "new items go to" picker on the Keychain screen. pane's menu on the desktop, MOVE beside EDIT on the phone. It is a separate act because it is not a save.
The two vaults are encrypted under different keys, so a move is a re-seal into one and a tombstone in the
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
What a move cannot do is reach a machine that has already synced the host, which is the same limit
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
to" picker on the Keychain screen and cannot be moved yet.
Both default to your personal vault and neither moves on its own, because an item put in a shared vault is Both default to your personal vault and neither moves on its own, because an item put in a shared vault is
visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
it can be filed under: a group is an item like any other and lives in exactly one vault. it can be filed under: a group is an item like any other and lives in exactly one vault.
+1 -1
View File
@@ -157,7 +157,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. | > | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. | > | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. | > | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
> | The **chevron beside the vault name** | The name alone, on the pane about an existing host: an item cannot be moved between vaults, because the two are encrypted under different keys and moving one is a delete and a retype. The half of the question that *does* have an answer — where a new host goes is asked in the host editor, as a picker beside the name; keys, passwords and buckets take theirs from the keychain screen's standing picker instead. | > | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. | > | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. | > | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. | > | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
@@ -262,10 +262,12 @@
<!-- <!--
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than ◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
one vault that can be written to, exactly as on the desktop — an existing host's vault cannot one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
change, because the two are encrypted under different keys and moving an item is a delete and a field of this form, because the two are encrypted under different keys and changing it is a
retype. Above GROUP rather than below it because it decides what GROUP can offer: a group is an re-seal into one vault and a tombstone in the other. That is MOVE, beside EDIT under the host,
item in one vault, so choosing a vault refills that list with that vault's groups. and it is separate so that it cannot happen as a side effect of saving something else. Above
GROUP rather than below it because it decides what GROUP can offer: a group is an item in one
vault, so choosing a vault refills that list with that vault's groups.
--> -->
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}"> <StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" Margin="0,4,0,0" /> <TextBlock Classes="label" Text="VAULT" Margin="0,4,0,0" />
@@ -279,7 +281,7 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<TextBlock Classes="body" <TextBlock Classes="body"
Text="A host in a shared vault is readable by everybody holding that vault's key, and it cannot be moved out afterwards." /> Text="A host in a shared vault is readable by everybody holding that vault's key. It can be moved out later, with MOVE under the host — what it cannot do is become unreadable to somebody who has already synced it." />
</StackPanel> </StackPanel>
<TextBlock Classes="label" Text="GROUP" Margin="0,4,0,0" /> <TextBlock Classes="label" Text="GROUP" Margin="0,4,0,0" />
@@ -432,6 +434,14 @@
BorderThickness="0,1,0,0" Padding="14,12"> BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<!--
Everything about connecting, in one group so that the move panel below can take the bar rather
than appear underneath it. A password box and a CONNECT button under a form asking which vault to
move the host into would be two unrelated questions in one bar, and the taller of the two would
push the other off a phone screen.
-->
<StackPanel Spacing="10" IsVisible="{Binding !IsMovingHost}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="CONNECT TO" /> <TextBlock Classes="label" Text="CONNECT TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedHost.Label}" /> <TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedHost.Label}" />
@@ -441,9 +451,10 @@
Shown only for a host that actually asks for one. A password box beside a key-authenticated host Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use. is an invitation to type a secret nothing will use.
The tick below it is the phone's whole answer to storing one, and on this head it is the only one: The tick below it is the phone's whole answer to storing one, and on this head it is the only
the keychain lists credentials here but has no editor to create one in, so before this a password one: the keychain lists credentials here but has no editor to create one in, so before this a
typed on a phone could only ever be typed again. The host editor's picker could then bind it. password typed on a phone could only ever be typed again. The host editor's picker could then
bind it.
--> -->
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}" <TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding ConnectPassword}" PlaceholderText="password"> Text="{Binding ConnectPassword}" PlaceholderText="password">
@@ -458,19 +469,62 @@
Text="Remember this password for this host" /> Text="Remember this password for this host" />
</CheckBox> </CheckBox>
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !SelectedHostAsksForAPassword}" <TextBlock Classes="detail" TextWrapping="Wrap"
IsVisible="{Binding !SelectedHostAsksForAPassword}"
Text="{Binding SelectedHostAuthenticationNote}" /> Text="{Binding SelectedHostAuthenticationNote}" />
<Button Classes="primary" Content="CONNECT" Command="{Binding ConnectCommand}" <Button Classes="primary" Content="CONNECT" Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<!-- <!--
Not asked for by the design, and here because a + that adds hosts with no way to correct one is a Not asked for by the design, and here because a + that adds hosts with no way to correct one is a
strange thing to ship. It costs nothing: the editor above serves both, so this is the same panel strange thing to ship. It costs nothing: the editor above serves both, so this is the same panel
opened on an existing row. opened on an existing row.
MOVE is beside it rather than inside it, and that is the same line the desktop's menu draws: which
vault a host is in is not a field of the host. The two vaults are encrypted under different keys,
so it is a re-seal into one and a tombstone in the other — nothing a SAVE could do. It shows only
where there is somewhere to move to; see VaultViewModel.CanMoveSelectedHost.
--> -->
<Button Classes="secondary" Height="44" Content="EDIT" <Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsMovingHost}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditSelectedHostCommand}" /> Command="{Binding EditSelectedHostCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
IsVisible="{Binding CanMoveSelectedHost}"
Command="{Binding MoveHostCommand}" />
</Grid>
<!--
◆ MOVING THE HOST TO ANOTHER VAULT, in the place the connect controls were. A picker and two
buttons rather than a question with a yes: what is being asked is which vault, and a move is
undone by moving it back.
The sentence is not decoration. A group and a tag are items of the vault the host is leaving, so
neither can come with it — and on a phone, where the status line afterwards is one line at the
bottom of a screen somebody has already navigated away from, saying it before the tap is the only
place it reliably gets read.
-->
<StackPanel Spacing="10" IsVisible="{Binding IsMovingHost}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveHostCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveHostCommand}" />
</Grid>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
+60 -19
View File
@@ -71,11 +71,12 @@
One row for all three panels, which is why what it says is on the view model rather than repeated One row for all three panels, which is why what it says is on the view model rather than repeated
three times here. See VaultViewModel.DrawerTitle. three times here. See VaultViewModel.DrawerTitle.
The subtitle is the vault this host is filed in, and the design's chevron beside it is not drawn: The subtitle is the vault this host is filed in, and the design's chevron beside it is still not
an item cannot be moved between vaults — the two are encrypted under different keys, so moving one drawn although a host can now be moved. The two are encrypted under different keys, so a move is a
is a delete and a retype — and a picker offering the move would be offering something no layer below re-seal into one vault and a tombstone in the other — it leaves the host's group and tags behind and
this can do. Choosing the vault at the moment a host is created is a different question and does gives it a new id, none of which a chevron on a subtitle would lead anybody to expect. It is in the
have an answer; it is in the editor, beside the name. menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
moment a host is created is a different question, and it is in the editor beside the name.
--> -->
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}" <Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
@@ -91,10 +92,15 @@
</StackPanel> </StackPanel>
<!-- <!--
The host's own two actions, behind a menu rather than as a row of buttons under the pane. They The host's own actions, behind a menu rather than as a row of buttons under the pane. They are
are what EDIT and DELETE were; a pane whose footer is CONNECT has one action worth a button, and what EDIT and DELETE were; a pane whose footer is CONNECT has one action worth a button, and the
the other two are things you go looking for. It hides with the deletion question for the reason rest are things you go looking for. It hides with the deletion question and with the move panel
the buttons did — see VaultViewModel.ShowsHostPaneActions. for the reason the buttons did — see VaultViewModel.ShowsHostPaneActions.
Moving is here rather than in the editor, and the separator says which side of the line it is
on: it is not a field of the host. The two vaults are encrypted under different keys, so it is a
re-seal into one and a tombstone in the other — nothing a SAVE could do — and a picker inside
the form would let somebody correcting a port move a machine by leaving it where they found it.
--> -->
<Button Grid.Column="1" Classes="flat paneicon" Content="⋯" <Button Grid.Column="1" Classes="flat paneicon" Content="⋯"
IsVisible="{Binding ShowsHostPaneActions}" IsVisible="{Binding ShowsHostPaneActions}"
@@ -102,6 +108,7 @@
<Button.Flyout> <Button.Flyout>
<MenuFlyout> <MenuFlyout>
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" /> <MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<MenuItem Header="Move to another vault…" Command="{Binding MoveHostCommand}" />
<Separator /> <Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" /> <MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</MenuFlyout> </MenuFlyout>
@@ -365,12 +372,16 @@
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" /> <TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!-- <!--
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it is the one decision on this ◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it decides who can read it and
form that cannot be changed afterwards: the vaults are encrypted under different keys, so because it is the one thing on this form that no later SAVE can change: the vaults are
moving an item between them is a delete and a retype. It is a field of the host rather encrypted under different keys, so changing it is a re-seal into one and a tombstone in
than the keychain screen's standing "new items go to" preference, and it is a separate the other. That is offered — "Move to another vault…" in the pane's own menu — and it is
selection from it — moving this one does not move that one, and a click over there cannot deliberately not this control, because a picker inside the form would move a machine as a
move a host half-typed here. side effect of correcting a port.
It is a field of the host rather than the keychain screen's standing "new items go to"
preference, and it is a separate selection from it — moving this one does not move that
one, and a click over there cannot move a host half-typed here.
Shown only while adding, and only where there is more than one vault that can be written Shown only while adding, and only where there is more than one vault that can be written
to. An existing host's row is not drawn at all rather than drawn disabled; the drawer's to. An existing host's row is not drawn at all rather than drawn disabled; the drawer's
@@ -390,7 +401,7 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A host in a shared vault is readable by everybody holding that vault's key, and it cannot be moved out afterwards." /> Text="A host in a shared vault is readable by everybody holding that vault's key. It can be moved out later, from this pane's own menu — what it cannot do is become unreadable to somebody who has already synced it." />
</StackPanel> </StackPanel>
<!-- <!--
@@ -602,13 +613,13 @@
<!-- ============ THE FOOTER ============ --> <!-- ============ THE FOOTER ============ -->
<!-- <!--
One row, and exactly one of its four contents is showing — the same by-construction exclusivity the One row, and exactly one of its five contents is showing — the same by-construction exclusivity the
panels above have, from the same flags. It is what each panel is for: connecting, saving a host, panels above have, from the same flags. It is what each panel is for: connecting, saving a host,
saving a group, or answering the question about deleting one. saving a group, answering the question about deleting one, or choosing where to move one.
◆ THE QUESTION TAKES CONNECT'S PLACE rather than stacking under it, as it always did with the row of ◆ THE QUESTION TAKES CONNECT'S PLACE rather than stacking under it, as it always did with the row of
buttons this footer replaced, so that DELETE cannot be pressed again while its own question is on buttons this footer replaced, so that DELETE cannot be pressed again while its own question is on
screen. See VaultViewModel.ShowsHostPaneActions. screen. See VaultViewModel.ShowsHostPaneActions. The move panel takes it for the same reason.
--> -->
<Border Grid.Row="2" Padding="12" Background="{StaticResource Panel}" <Border Grid.Row="2" Padding="12" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"> BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
@@ -624,6 +635,36 @@
<views:ConfirmDeleteCard /> <views:ConfirmDeleteCard />
</Border> </Border>
<!--
◆ MOVING THE HOST TO ANOTHER VAULT. A picker and two buttons, not a question with a yes: what
is being asked is which vault, and a move is undone by moving it back rather than by being
careful — so this is not drawn in the danger colours the deletion question uses.
The sentence under it is the part worth keeping. A group and a tag are items of the vault the
host is leaving, so neither can come; saying so here rather than only in the status line
afterwards is the difference between a warning and a surprise. What it deliberately does not
promise is anything about the key or password the host authenticates with — those resolve
across vaults, they are kept, and the status line names one that is left outside.
-->
<StackPanel Spacing="8" IsVisible="{Binding IsMovingHost}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveHostCommand}" />
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}"> <StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" /> <Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" /> <Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
@@ -1817,8 +1817,58 @@ internal sealed partial class VaultViewModel(
/// while the deletion question is up it would offer to ask it again — which is the rule /// while the deletion question is up it would offer to ask it again — which is the rule
/// <see cref="ShowsHostActions"/> has always carried for the row of buttons these two replaced. The /// <see cref="ShowsHostActions"/> has always carried for the row of buttons these two replaced. The
/// question takes CONNECT's place in the footer for the same reason it took DELETE's. /// question takes CONNECT's place in the footer for the same reason it took DELETE's.
/// <para>
/// The move panel is in that list too and for the same reason. It takes the footer as well, so leaving
/// CONNECT under it would put two things in one row — and the menu it came from would still be offering
/// to open it.
/// </para>
/// </remarks> /// </remarks>
internal bool ShowsHostPaneActions => IsShowingHostDetail && !IsConfirmingHostDeletion; internal bool ShowsHostPaneActions =>
IsShowingHostDetail && !IsConfirmingHostDeletion && !IsMovingHost;
/// <summary>
/// Whether the panel asking which vault to move the selected host to is up.
/// </summary>
/// <remarks>
/// The armed-state idiom this window uses everywhere instead of a modal, and here it carries a choice
/// rather than a yes: the question is not "are you sure" but "which vault", and the sentence beside it
/// says what will be left behind. See <see cref="MoveHost"/>.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
private bool isMovingHost;
/// <summary>Which host the open move panel is about. Null when it is closed.</summary>
/// <remarks>
/// Held rather than read off the selection, so the panel survives a reload replacing every row object —
/// see <see cref="OnSelectedHostChanged"/>, which is the only thing that reads it.
/// </remarks>
private Guid? movingHostId;
/// <summary>Where the selected host could be moved: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveVault;
/// <summary>
/// Whether the selected host has anywhere to move to.
/// </summary>
/// <remarks>
/// <para>
/// Asked so the phone can leave the button out rather than offer one that answers with a refusal — it
/// has room for two buttons under a host and no room to explain a third that does nothing. The desktop
/// keeps its menu entry either way: a menu that grew and shrank would be a menu whose items move.
/// </para>
/// <para>
/// It counts vaults rather than merely asking whether there are two, because the answer is per host: a
/// host already in the only other writable vault has nowhere to go, and a read-only vault is not
/// somewhere anything can be moved to.
/// </para>
/// </remarks>
internal bool CanMoveSelectedHost =>
SelectedHost is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
/// <summary> /// <summary>
/// What the drawer's header says it is about. /// What the drawer's header says it is about.
@@ -1839,11 +1889,12 @@ internal sealed partial class VaultViewModel(
/// The line under it: which keychain this is filed in, or what a group is for. /// The line under it: which keychain this is filed in, or what a group is for.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item /// The vault's name and not a picker for it, although the design draws one with a chevron. A host can
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted /// be moved between vaults now — see <see cref="MoveHost"/> — and it is still not a field: the move is
/// under different keys — so a control offering the move would be offering something no layer below /// a re-seal under another key and a tombstone under this one, so binding it to a control that saves
/// this can do. Where a *new* item goes is chosen on the keychain screen's own picker; see /// with the rest of the form would let somebody correcting a port move a machine by leaving a picker
/// <see cref="TargetVaults"/>. /// where they found it. It has its own panel and its own button. Where a *new* item goes is chosen on
/// the keychain screen's own picker; see <see cref="TargetVaults"/>.
/// </remarks> /// </remarks>
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
{ {
@@ -1961,9 +2012,10 @@ internal sealed partial class VaultViewModel(
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Only while creating, and only where there is more than one vault to choose between. An existing /// Only while creating, and only where there is more than one vault to choose between. An existing
/// host's vault is not editable and the picker is not shown disabled beside it: the two are encrypted /// host's vault is not a field of this form and the picker is not shown disabled beside it: the two
/// under different keys, so moving an item is a delete and a retype rather than a save — see the note /// are encrypted under different keys, so moving one is a re-seal and a tombstone rather than a save.
/// on the drawer's header, which says where the host is filed. /// That is offered — by <see cref="MoveHost"/>, from the pane's own menu — and it is a separate act
/// precisely because it must not happen as a side effect of saving something else.
/// </para> /// </para>
/// <para> /// <para>
/// Hidden at one vault rather than shown with a single option, which is the rule /// Hidden at one vault rather than shown with a single option, which is the rule
@@ -4907,6 +4959,185 @@ internal sealed partial class VaultViewModel(
return imported; return imported;
} }
/// <summary>
/// Opens the panel that asks which vault the selected host should move to.
/// </summary>
/// <remarks>
/// <para>
/// A panel rather than a picker in the host editor, and the reason is what a move is underneath: the
/// item is re-sealed under another vault's key and the one it came from gets a tombstone — see
/// <c>VaultItemRepository.MoveAsync</c>. That is not a field of the host and must not be saved with
/// one, or somebody correcting a port would move a machine into a colleague's vault by leaving a
/// picker where they found it.
/// </para>
/// <para>
/// Refused for a host written by a newer client, exactly as editing one is: the move re-encodes the
/// payload, so a field this build cannot represent would be dropped on the way across.
/// </para>
/// </remarks>
[RelayCommand]
private void MoveHost()
{
if (SelectedHost is not { } row || AHostEditorIsInTheWay())
{
return;
}
if (row.IsReadOnly)
{
Status = "This host was written by a newer version of DodoSSH. Moving it would re-encode it "
+ "here and lose what this build cannot read. Update first.";
return;
}
BuildMoveVaultChoices(row.VaultId);
if (MoveVaultChoices.Count == 0)
{
// The one-vault case, and the honest sentence rather than an empty picker. It is also what
// somebody in a team whose only other vault is read-only sees.
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
return;
}
// Disarms a deletion aimed at the same host. Two questions about one machine, one of which
// destroys it, is not a pane anybody should have to read carefully.
PendingDeletion = null;
movingHostId = row.EntityId;
IsMovingHost = true;
Status = string.Empty;
}
/// <summary>Abandons the move panel.</summary>
[RelayCommand]
private void CancelMoveHost()
{
IsMovingHost = false;
movingHostId = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
Status = string.Empty;
}
/// <summary>
/// Moves the selected host into the chosen vault.
/// </summary>
/// <remarks>
/// <para>
/// <b>The group and the tags are left behind, and that is the whole of what makes this honest.</b> Both
/// are items of the vault the host is leaving: the group picker in the editor offers one vault's groups
/// and the tag chips are drawn from one vault's tags, so a host carrying either across would point at
/// something the destination does not contain. On this machine it would still resolve — groups and tags
/// are resolved across every readable vault — and for everybody else in the destination it would dangle,
/// which means the mover and their colleagues would see two different hosts. Cleared and reported beats
/// carried and invisible.
/// </para>
/// <para>
/// <b>The key or password binding is kept</b>, 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 it one that cannot connect. What it can do is say
/// when the binding is now in a different vault from the host, because that is exactly what the other
/// members of the destination will not be able to resolve.
/// </para>
/// <para>
/// The row is re-selected by its new id afterwards. A move that left the pane on a host that no longer
/// exists would read as the machine having been deleted.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveHostAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row || SelectedMoveVault is not { } target)
{
return;
}
var name = target.Name;
var dropped = WhatWasLeftBehind(row.Host);
var stranded = BindingOutside(row.Host, target.VaultId);
var moved = row.Host with { GroupId = null, TagIds = TagSet.Empty };
IsMovingHost = false;
movingHostId = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
await RunAsync(
"Moving…",
async () =>
{
var entityId = await session.Hosts
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId);
Status = $"Moved '{row.Label}' to {name}.{dropped}{stranded}";
}).ConfigureAwait(true);
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
// them and not the other shows the host twice or not at all until the next pass.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>What the move left behind, said only when it left something.</summary>
private static string WhatWasLeftBehind(HostSecret host) =>
(host.GroupId is not null, host.TagIds.Count > 0) switch
{
(true, true) => " Its group and tags were left behind — both belong to the vault it came from.",
(true, false) => " Its group was left behind — a group belongs to the vault it is in.",
(false, true) => " Its tags were left behind — a tag belongs to the vault it is in.",
_ => string.Empty,
};
/// <summary>
/// The warning about a key or password that is not in the vault the host has moved to.
/// </summary>
/// <remarks>
/// Named rather than counted, because which one it is decides what to do about it — and the answer is
/// usually to put a copy of that key in the destination vault, which needs to know which key.
/// </remarks>
private string BindingOutside(HostSecret host, Guid vaultId)
{
if (host.SshKeyId is { } keyId
&& Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
&& key.VaultId != vaultId)
{
return $" It still authenticates with the key '{key.Label}', which is in another vault — "
+ "everybody else in this one will find that binding unresolvable.";
}
if (host.CredentialId is { } credentialId
&& Credentials.FirstOrDefault(row => row.EntityId == credentialId) is { } credential
&& credential.VaultId != vaultId)
{
return $" It still authenticates with the password '{credential.Label}', which is in another "
+ "vault — everybody else in this one will find that binding unresolvable.";
}
return string.Empty;
}
/// <summary>Fills the move panel's picker with every vault this session can write to but that one.</summary>
private void BuildMoveVaultChoices(Guid vaultId)
{
MoveVaultChoices.Clear();
foreach (var choice in session.ReadableVaults
.Where(vault => vault.CanWrite && vault.VaultId != vaultId)
.OrderByDescending(vault => vault.IsPersonal)
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
.Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal)))
{
MoveVaultChoices.Add(choice);
}
SelectedMoveVault = MoveVaultChoices.FirstOrDefault();
}
/// <summary>Asks whether the selected host should go.</summary> /// <summary>Asks whether the selected host should go.</summary>
/// <remarks> /// <remarks>
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does /// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
@@ -7039,10 +7270,10 @@ internal sealed partial class VaultViewModel(
/// Moves a half-typed host into the vault just chosen for it. /// Moves a half-typed host into the vault just chosen for it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Only while creating. An existing host's vault is fixed, and this guard is what makes that true /// Only while creating, and this guard is what makes that true rather than the view merely not drawing
/// rather than the view merely not drawing the control: an item cannot be moved between vaults, so a /// the control. An existing host <em>can</em> change vaults — see <see cref="MoveHost"/> — but not this
/// path that reassigned this on an edit would write the host into a second vault and leave the /// way and not as part of a save: reassigning it here on an edit would write the host into a second
/// original behind. /// vault and leave the original behind, which is a fork rather than a move.
/// </remarks> /// </remarks>
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value) partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
{ {
@@ -7427,6 +7658,7 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(IsDrawerOpen)); OnPropertyChanged(nameof(IsDrawerOpen));
OnPropertyChanged(nameof(IsShowingHostDetail)); OnPropertyChanged(nameof(IsShowingHostDetail));
OnPropertyChanged(nameof(ShowsHostPaneActions)); OnPropertyChanged(nameof(ShowsHostPaneActions));
OnPropertyChanged(nameof(CanMoveSelectedHost));
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to // Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each // one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
@@ -7434,6 +7666,17 @@ internal sealed partial class VaultViewModel(
SelectedSidebarRow = value; SelectedSidebarRow = value;
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId); DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
// The move panel goes with the selection, as the deletion question does — and by entity id for the
// same reason DisarmIfAimedElsewhere compares them: a background pass replaces every row object in
// the list, so a panel closed on row identity would fold up once a minute under somebody who was
// still choosing a vault in it. A click onto a different host is the case that needs handling, and
// it is cleared rather than re-aimed: which vault to move to is a choice about the host it was
// asked for.
if (IsMovingHost && movingHostId != value?.EntityId)
{
CancelMoveHostCommand.Execute(null);
}
} }
/// <remarks> /// <remarks>
@@ -38,6 +38,15 @@ public sealed class HostRepository(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
hosts.UpdateAsync(vaultId, entityId, host, cancellationToken); hosts.UpdateAsync(vaultId, entityId, host, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
HostSecret host,
CancellationToken cancellationToken) =>
hosts.MoveAsync(fromVaultId, toVaultId, entityId, host, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" /> /// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
hosts.DeleteAsync(vaultId, entityId, cancellationToken); hosts.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -234,6 +234,72 @@ internal sealed class VaultItemRepository<TSecret>(
} }
} }
/// <summary>
/// Moves an item into another vault.
/// </summary>
/// <param name="fromVaultId">The vault it is in.</param>
/// <param name="toVaultId">The vault it should be in.</param>
/// <param name="entityId">The item.</param>
/// <param name="secret">
/// What to write into the destination. The caller's, rather than read from here, because moving is the
/// one operation where the item does not arrive unchanged: references to things that live in the vault
/// it is leaving are the mover's to resolve, and this layer has no way to know which those are.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The id the item has in its new vault.</returns>
/// <remarks>
/// <para>
/// <b>A copy and a tombstone, and it cannot be anything else.</b> An item's payload is sealed under
/// its vault's key and its AAD binds the vault, the entity id and the item version — so there is no
/// edit that moves one, and no server call that 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.
/// </para>
/// <para>
/// <b>A new id, deliberately.</b> 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.
/// Callers holding the old id have to take the new one back.
/// </para>
/// <para>
/// <b>The write comes first and the tombstone second</b>, which decides what an interruption leaves
/// behind: a copy in both vaults, which is visible and can be deleted, rather than a tombstone with
/// nothing on the other side, which is the host gone. Both are queued rather than sent, so the window
/// is a crash between two local writes — narrow, and worth choosing the survivable side of anyway.
/// </para>
/// <para>
/// Two activity lines, not one: a create in the destination and a delete in the source, which is what
/// the 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.
/// </para>
/// </remarks>
internal async Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
TSecret secret,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(secret);
if (fromVaultId == toVaultId)
{
throw new ArgumentException(
"That item is already in that vault.", nameof(toVaultId));
}
// Both keys before either write, so a destination this session cannot write to is refused with
// nothing having happened rather than after the source item has gone.
_ = Key(fromVaultId);
_ = Key(toVaultId);
var moved = await CreateAsync(toVaultId, secret, cancellationToken).ConfigureAwait(false);
await DeleteAsync(fromVaultId, entityId, cancellationToken).ConfigureAwait(false);
return moved;
}
/// <summary> /// <summary>
/// Deletes an item. /// Deletes an item.
/// </summary> /// </summary>
@@ -223,6 +223,36 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty()); 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> /// <remarks>
/// <para> /// <para>
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it. /// 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); 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> /// <remarks>
/// <para> /// <para>
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created, /// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,