Public Access
Make a host take what its group lends it, everywhere it is read
Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host against its groups; nothing asked it to. This is the wiring, and it is mostly one change repeated: read the resolved host, not the stored one. TryBuildAuthentication and TryBuildConnectionRequest now take the resolved value beside the stored one, which is where group context was being lost. It is the only authentication resolution in the product — both heads and both transports come through it — so a host inheriting its binding would otherwise have been offered a password prompt on every screen at once. The credential-username fallback becomes three levels, and Complete still refuses an empty username, but now only after the chain has been walked; refusing before it would refuse exactly the hosts inheritance exists to serve. HostRowViewModel carries its ResolvedHost, resolved once when the list is built. Address, Authentication and Dialled read it, so a row cannot disagree with itself about what it dials — and MainWindowViewModel.Rank searches Address, so a host inheriting 2222 that displayed 22 would have been unfindable by the port it actually answers on. HostsBoundTo counts over the resolved binding, which is the difference between a warning and a silence: a key bound once on a group and inherited by twenty hosts named nobody, would have been deleted, and would then have refused all twenty at connect time. HostFields.From is answered by a refusal rather than by threading a group list through the sync engine. A relay host may not inherit its port. The reason is stronger than the convenience: a plaintext column is a derived duplicate the client supplies when it pushes *this* host, so an inherited port would make it depend on another item — editing a group would change what the relay dials for every host beneath it, except that nothing re-pushes those hosts, so the server would keep dialling the old port until each was next touched for some unrelated reason. A stale wire on the relay path connects the user to the wrong service. The editor distinguishes unset from explicit in both directions. An empty port box means "take the group's" and shows what that will be as a placeholder, following the group picker as it moves — a pre-filled 2222 would have been indistinguishable from one the user typed, and saving would have pinned it. The authentication picker gains a fourth entry, offered only to a host in a group, because for an ungrouped host it would behave exactly like the first. Which found a real defect while the tests were being written. Filing an ungrouped host into a group silently pinned it to a typed password: the picker had no "Inherit" entry when it opened, so it sat on "Password (ask each time)", and saving wrote that as a decision — the host would have been pinned to a prompt nobody asked for and the group's key would never have reached it. Two guards now: the picker is rebuilt when the group changes, and BuildHost writes AsksForPassword only for a host that had the alternative on offer. The group editor is here too, and the plan never assigned it a step. Without it no group can carry a default, so every line above would have been unreachable. It grows a parent picker that leaves out the group itself and everything beneath it — a courtesy rather than the guarantee, since a cycle assembled from two offline re-parents was never offered that list — and three defaults beside the name, each of which may be left empty because "lend nothing" is an answer. Tags are stored and not editable. TagIds merges, encodes and resolves; no screen can set one yet, and the editor carries the set through a save untouched so a client that can set them does not lose them to somebody editing a port. Eight new tests, and they dial. That is the point of them: a resolved value that never reaches SshConnectionRequest is a label, and every one of these failures would be silent — a host connecting to the wrong port, or being asked for a password it does not need, with nothing on screen admitting it. Verified by the whole suite: 1390 tests over nineteen projects, none failing. Both heads build. Nothing on the phone has changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3325,6 +3325,244 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.EditingGroupId.ShouldBeNull("the box goes back to creating once the rename is saved");
|
||||
}
|
||||
|
||||
// ---- What a host takes from its group ----
|
||||
//
|
||||
// The tests below dial. That is the point of them: a resolved value that never reaches
|
||||
// SshConnectionRequest is a label, and every one of these failures would be silent — a host connecting
|
||||
// to the wrong port, or being asked for a password it does not need, with nothing on screen admitting
|
||||
// it. AddHostAsync gives its host a username, so each of these clears what it is about first.
|
||||
|
||||
[Fact]
|
||||
public async Task AHostThatStatesNoPort_DialsItsGroups()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222);
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
|
||||
// The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
|
||||
// factory is ever reached, and this would pass no matter what port was resolved.
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(2222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostThatPinsItsOwnPort_KeepsItUnderAGroupThatSaysOtherwise()
|
||||
{
|
||||
// The other direction, and the one that decides whether inheritance is safe to turn on: a host that
|
||||
// was explicit must not start dialling somewhere else because somebody edited a group.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222);
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorPort = 22;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
|
||||
// The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
|
||||
// factory is ever reached, and this would pass no matter what port was resolved.
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostThatStatesNoUsername_LogsInAsItsGroups()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", username: "deploy");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorUsername = string.Empty;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
|
||||
// The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
|
||||
// factory is ever reached, and this would pass no matter what port was resolved.
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldHaveSingleItem().Username.ShouldBe("deploy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsDefaultPort_ShowsAsThePlaceholderInTheHostEditor()
|
||||
{
|
||||
// What makes an empty box honest. Without this the form asks the user to leave a field blank and
|
||||
// says nothing about what blank will get them.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPortPlaceholder.ShouldBe("22", "an ungrouped host falls to the end of the chain");
|
||||
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.EditorPortPlaceholder.ShouldBe(
|
||||
"2222", "the placeholder follows the group picker, or it describes the wrong group");
|
||||
|
||||
vault.EditorUsernamePlaceholder.ShouldBe("deploy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostUnderAGroupThatBindsAKey_DoesNotAskForAPassword()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", key: "deploy");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
|
||||
vault.SelectedHostAuthenticationNote.ShouldContain("production");
|
||||
Host(vault, "prod-db").Authentication.ShouldBe("key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostPinnedToATypedPassword_KeepsAskingUnderAGroupThatBindsAKey()
|
||||
{
|
||||
// The failure worth ruling out above every other one here. A host deliberately set back to a typed
|
||||
// password must not start authenticating with the fleet's key because somebody set a group default.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", key: "deploy");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedAuthentication.ShouldBe(
|
||||
AuthenticationChoice.Inherited, "a host that binds nothing under a group is inheriting, not typing");
|
||||
|
||||
vault.EditorSelectedAuthentication = AuthenticationChoice.Typed;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
|
||||
Host(vault, "prod-db").Host.AsksForPassword.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeyBoundOnlyByAGroup_WarnsAboutTheHostsBeneathItBeforeItIsDeleted()
|
||||
{
|
||||
// Counting each host's own ids would warn about nobody here, and then refuse every host beneath the
|
||||
// group at connect time. The warning is the only thing standing between the two.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", key: "deploy");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedKey = vault.Keys.Single();
|
||||
vault.DeleteKeyCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().Usage.ShouldContain("prod-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsParentPicker_LeavesOutItselfAndEverythingBeneathIt()
|
||||
{
|
||||
// A cycle cannot be made here. It can still arrive from two offline re-parents, which is why the
|
||||
// walk carries a visited set — this only keeps a user from doing it to themselves.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddGroupAsync(vault, "estate");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupParentAsync(vault, "production", "estate");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorParentChoices
|
||||
.Select(choice => choice.Label)
|
||||
.ShouldBe(["No group"], "estate cannot be its own parent, and production already sits under it");
|
||||
}
|
||||
|
||||
private static async Task SetGroupDefaultsAsync(
|
||||
VaultViewModel vault,
|
||||
string group,
|
||||
int? port = null,
|
||||
string? username = null,
|
||||
string? key = null)
|
||||
{
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorDefaultPort = port;
|
||||
vault.GroupEditorDefaultUsername = username ?? string.Empty;
|
||||
|
||||
if (key is not null)
|
||||
{
|
||||
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices.Single(
|
||||
choice => string.Equals(choice.Label, key, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
|
||||
{
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
||||
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// ---- Snippets ----
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user