Public Access
Let a snippet be shared to a vault, the way a host already can
A snippet was a first-class vault item everywhere except where it mattered: the crypto, the sync, the server table and every registry already treated it exactly as they treat a host, and the screen read it out of the active vault alone. So the one command a team most obviously wants to hold in common — the incantation somebody worked out once and everybody else retypes — was the only item kind that could not leave the machine that wrote it. The read is the half that had to come first, and it is why this is not simply a MoveAsync. ReloadSnippetsAsync now lists every readable vault rather than the active one, in the shape ReloadHostsAsync and ReloadKeysAsync already use: the vault new items go into first, then by vault name, then by label, with a badge on the row only where there is more than one vault to tell apart. Without that, a snippet moved into a team vault would have disappeared from the very screen that moved it, and one a colleague wrote there would never have arrived at all — sharing would have looked like losing. Three writes were pinned to the active vault and each one broke differently once the list spanned several. The delete tombstoned in the wrong vault, which tombstones nothing and leaves the snippet on screen. The save is the bad one: an update sent to the active vault creates a second snippet there and leaves the team original untouched, so the person editing sees their fix and nobody else ever does. That is a fork with no symptom, which is why the vault is now a parameter and the screen latches it when the editor opens — the chosen vault for a new snippet, the row own vault for an existing one — rather than reading it back off a selection that can move under a half-typed form. VaultViewModel has carried editingHostVaultId for the same reason since hosts crossed vaults. Two controls rather than one, and that is the same line the host pane draws. The editor asks which vault a new snippet is filed into; MOVE re-seals an existing one under another key and tombstones the first. Putting the second inside the first would let somebody correcting a typo hand a command to a team by leaving a picker where they found it, so the picker is not drawn for an existing snippet at all. Both live on SnippetsViewModel rather than VaultViewModel because this screen owns its editor, unlike the host drawer; the writing they ask for is still the vault. A snippet crosses whole, which is the one way this is simpler than the host it copies. A host leaves its group and its tags behind because both are items of the vault it came from and would dangle for everybody in the destination. A snippet is a label, a command and a note, and none of them points at anything — so there is nothing to strip, nothing to report as left behind, and what the copy says instead is the thing that is actually at stake: who can read the command afterwards. For a command that may carry a hostname or a path, that is the whole decision. Two judgement calls worth finding later. A hidden vault now hides its snippets, filtered in the screen projection rather than in VaultViewModel.Snippets, which is the rule keys and passwords already follow: the list stays whole so nothing that resolves against it breaks, and the projection is what a preference about reading gets to change. And the nav rail count is left spanning vaults unfiltered, because Vault.Hosts.Count beside it is unfiltered too — filtering one of the four would make the rail disagree with itself. Four flow tests in VaultSharingTests, beside the host ones they mirror: the move re-seals with a new id and carries the runs-on-insert flag across, the move with nowhere to go refuses rather than opening an empty picker, the editor files into the vault chosen on it, and the edit of a shared snippet goes back to its own vault instead of forking. That last one is the regression the latch exists for and the only one whose absence has no visible symptom. Plus a layout test with the move panel open, since that paragraph wraps in a 300-pixel column and the desktop pane it lands in is measured. The whole suite passes: 1660 tests, none failing.
This commit is contained in:
@@ -883,6 +883,38 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The move panel, which is how a snippet gets shared and takes the insert controls' place while it is
|
||||
/// up: a heading, a combo box, a wrapping paragraph and two buttons, in the same 300-pixel column the
|
||||
/// detail pane has. The paragraph is the risk — it is what says who can read the command afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The state is set here rather than through <c>MoveCommand</c>, which would refuse: this fixture's
|
||||
/// account holds one vault, and the command declines rather than open a picker with nothing in it. The
|
||||
/// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>. The same arrangement, and the same
|
||||
/// reason, as <see cref="TheHostDrawerFitsWithTheMovePanelOpen"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSnippetsScreenFitsWithTheMovePanelOpen()
|
||||
{
|
||||
await SeedSnippetsAsync();
|
||||
|
||||
var snippets = NewSnippetsScreen();
|
||||
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
|
||||
|
||||
snippets.MoveVaultChoices.Add(
|
||||
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
||||
|
||||
snippets.SelectedMoveVault = snippets.MoveVaultChoices[0];
|
||||
snippets.IsMoving = true;
|
||||
|
||||
snippets.ShowsSelectionActions.ShouldBeFalse("the panel takes the pane rather than sharing it");
|
||||
|
||||
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the move panel open"), snippets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
|
||||
{
|
||||
@@ -1919,6 +1951,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
private async Task SeedSnippetsAsync()
|
||||
{
|
||||
await vault.SaveSnippetAsync(
|
||||
vault.TargetVaultId,
|
||||
null,
|
||||
new SnippetSecret
|
||||
{
|
||||
@@ -1929,6 +1962,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
Token);
|
||||
|
||||
await vault.SaveSnippetAsync(
|
||||
vault.TargetVaultId,
|
||||
null,
|
||||
new SnippetSecret
|
||||
{
|
||||
|
||||
@@ -1475,6 +1475,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