Public Access
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing on either head has changed, which is deliberate — the plan orders these first because everything the editors will bind to has to exist and be merge-safe before a screen can offer it. HostGroupSecret gains a parent and four defaults, and the codec gains the version rule it never had. It stamped CurrentSchemaVersion unconditionally, which was harmless with one field and one version and stops being harmless here: upgrading one machine and renaming any group would have made that group uneditable on every machine still on the old build. It now emits the lowest version that loses nothing, so a flat group with no defaults still encodes at version 1, byte for byte, pinned against a literal. Tags become a real item over the reserved slot. Secret, codec, merge, cipher, repository, both registries, the EF entity and a generated AddTagItem migration. TagCipher names AadResourceType.Tag as a constant rather than casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum and 5 there is Credential — a cast would seal every tag under the resource type for a password, encrypt and decrypt perfectly on the machine that wrote it, and only fail when another implementation refused the item, by which time the AAD is frozen into stored ciphertext. HostTag stays reserved and unused: the one thing the join buys over a set on the host is bought instead by merging TagIds per id. HostSecret grows TagIds and Port goes nullable, which is the change with the widest blast radius and the only one that loses an item rather than locking one. A host with no port of its own omits the property, an older build reads int Port as 0, and TryValidate refuses it — unreadable rather than read-only. That cost is confined to hosts which actually inherit, because the version is a maximum over the fields present; the alternative, writing 22 into every host, is the lie inheritance exists to stop telling. One decision the plan did not specify. "Three states where there were two" is four — key, credential, typed password, or the group's answer — and two nullable ids carry three. Naming neither id now means inherit, so AsksForPassword says "a typed password even under a group that lends a key" out loud. Only true is ever written and a decoded false folds back to null, so a host that never touched it encodes as it always did. Nothing already stored changed meaning: no group could lend a binding before this build, so every existing host resolves exactly as it did. HostInheritance is the resolver, and its visited set is load-bearing rather than defensive. Two clients can each re-parent A under B and B under A while offline; the merge sees one item against one item and the server sees ciphertext, so nothing upstream can refuse the pair. With inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable sidebar — it is a shell that never opens. Stopping at the first repeat degrades it to a group that reads as a root, and clearing the parent is the repair. A tag set turns out to be the one field on a host that can never ask the user anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so the both-sides-moved-differently branch of the keyed merge is unreachable — asserted over the whole eight-row matrix. The conflict loop is kept anyway, because that proof is one edit from ceasing to hold and what it would cause is a discarded tag nothing records. Three guard tests failed by design and were fixed rather than relaxed: the ordered pull filter, the AAD pinning table, and the server's refusal of a plaintext parent — that last one survives with its reason rewritten, because the refusal now means "the parent is not the server's to hold" rather than "there is no such thing as a parent". The prose that said groups are flat is rewritten in all four places it appeared, not deleted. The five view-model sites that read Port directly now go through the resolver, which is a down payment on step 4 rather than the whole of it. HostFields.From still emits the stored port, and that is the one remaining place where an unresolved read would be a wrong wire rather than a wrong label. Verified by the whole suite: 1382 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display, because nothing on a display has changed yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
258 lines
11 KiB
C#
258 lines
11 KiB
C#
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
|
|
|
namespace DodoSSH.Client.Domain.Tests;
|
|
|
|
/// <summary>
|
|
/// Walking a host's group chain for the values it did not state itself.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This is the code the connect path runs, so its failures are not cosmetic. A wrong port dials the wrong
|
|
/// machine or nothing at all; a wrong binding sends a password to a host set up for key-only access; and a
|
|
/// walk that does not terminate is a shell that never opens, which is the one failure a user cannot even
|
|
/// describe.
|
|
/// </para>
|
|
/// <para>
|
|
/// The cycle tests are the reason the walk carries a visited set at all. A cycle cannot be created through
|
|
/// the editor and cannot be seen by the merge, which resolves one group against one group, or by the
|
|
/// server, which cannot read the payload — so it arrives assembled from two offline re-parents or not at
|
|
/// all. What these pin is that it degrades to a group reading as a root rather than hanging.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class HostInheritanceTests
|
|
{
|
|
private static Guid Estate { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a");
|
|
|
|
private static Guid Region { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
|
|
|
private static Guid TeamCredential { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
|
|
|
[Fact]
|
|
public void AHostThatStatesEverything_InheritsNothing()
|
|
{
|
|
var host = Host(port: 2222, username: "deploy", sshKeyId: DeployKey, groupId: Production);
|
|
|
|
var resolved = HostInheritance.Resolve(
|
|
host,
|
|
Groups((Production, Group(defaultPort: 9999, defaultUsername: "root"))));
|
|
|
|
resolved.Port.Value.ShouldBe(2222);
|
|
resolved.Port.IsInherited.ShouldBeFalse();
|
|
resolved.Username.Value.ShouldBe("deploy");
|
|
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.SshKey);
|
|
resolved.Binding.EntityId.ShouldBe(DeployKey);
|
|
resolved.Binding.IsInherited.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostThatStatesNothing_TakesItsGroupsValuesAndSaysWhichGroup()
|
|
{
|
|
// The second half matters as much as the first: the editor draws an inherited value as a
|
|
// placeholder behind an empty box, so it has to know the value came from somewhere else.
|
|
var host = Host(port: null, username: null, groupId: Production);
|
|
|
|
var resolved = HostInheritance.Resolve(
|
|
host,
|
|
Groups((Production, Group(
|
|
defaultPort: 2222,
|
|
defaultUsername: "deploy",
|
|
defaultCredentialId: TeamCredential))));
|
|
|
|
resolved.Port.Value.ShouldBe(2222);
|
|
resolved.Port.FromGroupId.ShouldBe(Production);
|
|
resolved.Username.Value.ShouldBe("deploy");
|
|
resolved.Username.FromGroupId.ShouldBe(Production);
|
|
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
|
|
resolved.Binding.EntityId.ShouldBe(TeamCredential);
|
|
resolved.Binding.FromGroupId.ShouldBe(Production);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostWithNoGroupAndNothingStated_FallsBackToTwentyTwoAndATypedPassword()
|
|
{
|
|
// Every host stored before any of this existed. Nothing changed underneath them: naming neither a
|
|
// key nor a credential resolved to a typed password then, and resolves to one now.
|
|
var resolved = HostInheritance.Resolve(Host(port: null, username: null), Groups());
|
|
|
|
resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
|
|
resolved.Port.IsInherited.ShouldBeFalse();
|
|
resolved.Username.Value.ShouldBeNull();
|
|
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
|
|
resolved.Binding.EntityId.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void TheNearestGroupThatStatesAValue_Wins()
|
|
{
|
|
var host = Host(port: null, username: null, groupId: Production);
|
|
|
|
var resolved = HostInheritance.Resolve(
|
|
host,
|
|
Groups(
|
|
(Production, Group(parentId: Region, defaultPort: 2222)),
|
|
(Region, Group(parentId: Estate, defaultPort: 9999, defaultUsername: "deploy")),
|
|
(Estate, Group(defaultUsername: "root"))));
|
|
|
|
resolved.Port.Value.ShouldBe(2222);
|
|
resolved.Port.FromGroupId.ShouldBe(Production);
|
|
|
|
// Each field is resolved on its own, so a group that answers one question does not stop the walk
|
|
// for the others.
|
|
resolved.Username.Value.ShouldBe("deploy");
|
|
resolved.Username.FromGroupId.ShouldBe(Region);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostWithAnEmptyUsername_HasNoUsernameRatherThanTheGroups()
|
|
{
|
|
// The three states in one nullable string. Null used to mean "no username", which the connect path
|
|
// refuses; it now means "ask the group", so the refusal has to stay reachable or a host under a
|
|
// group could never opt out of the group's user.
|
|
var resolved = HostInheritance.Resolve(
|
|
Host(username: string.Empty, groupId: Production),
|
|
Groups((Production, Group(defaultUsername: "deploy"))));
|
|
|
|
resolved.Username.Value.ShouldBe(string.Empty);
|
|
resolved.Username.IsInherited.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostPinnedToATypedPassword_DoesNotPickUpItsGroupsKey()
|
|
{
|
|
// The failure worth ruling out above all others in this file. A host deliberately put back on a
|
|
// typed password must not silently start authenticating with the fleet's key because somebody set a
|
|
// default on the group above it.
|
|
var resolved = HostInheritance.Resolve(
|
|
Host(asksForPassword: true, groupId: Production),
|
|
Groups((Production, Group(defaultSshKeyId: DeployKey))));
|
|
|
|
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
|
|
resolved.Binding.EntityId.ShouldBeNull();
|
|
resolved.Binding.IsInherited.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostNamingACredentialUnderAGroupNamingAKey_ResolvesToOneBindingAndItIsTheHosts()
|
|
{
|
|
// Two individually valid records that per-record validation cannot catch, because neither one is
|
|
// wrong on its own. The exclusion is enforced again here, and the nearer statement wins.
|
|
var resolved = HostInheritance.Resolve(
|
|
Host(credentialId: TeamCredential, groupId: Production),
|
|
Groups((Production, Group(defaultSshKeyId: DeployKey))));
|
|
|
|
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
|
|
resolved.Binding.EntityId.ShouldBe(TeamCredential);
|
|
}
|
|
|
|
[Fact]
|
|
public void ANearerGroupsBinding_BeatsAFurtherOnesEvenWhenTheyDiffer()
|
|
{
|
|
var resolved = HostInheritance.Resolve(
|
|
Host(port: null, groupId: Production),
|
|
Groups(
|
|
(Production, Group(parentId: Estate, defaultCredentialId: TeamCredential)),
|
|
(Estate, Group(defaultSshKeyId: DeployKey))));
|
|
|
|
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
|
|
resolved.Binding.FromGroupId.ShouldBe(Production);
|
|
}
|
|
|
|
[Fact]
|
|
public void AGroupDeletedOnAnotherMachine_LeavesTheHostUngroupedRatherThanFailing()
|
|
{
|
|
// A dangling id is the ordinary outcome of a delete, not a corruption: deleting a group does not
|
|
// rewrite the hosts that named it, deliberately. See HostGroupRepository.
|
|
var resolved = HostInheritance.Resolve(Host(port: null, username: null, groupId: Production), Groups());
|
|
|
|
resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
|
|
resolved.Username.Value.ShouldBeNull();
|
|
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public void AGroupWhoseParentWasDeleted_StopsAtThatGroupRatherThanFailing()
|
|
{
|
|
var resolved = HostInheritance.Resolve(
|
|
Host(port: null, username: null, groupId: Production),
|
|
Groups((Production, Group(parentId: Estate, defaultPort: 2222))));
|
|
|
|
resolved.Port.Value.ShouldBe(2222);
|
|
resolved.Username.Value.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ATwoGroupCycle_ResolvesRatherThanHanging()
|
|
{
|
|
// The state the old "groups are flat" decision existed to prevent, arriving anyway: two clients each
|
|
// re-parenting A under B and B under A while offline. The merge sees one group against one group and
|
|
// the server sees ciphertext, so nothing upstream can refuse the pair — which is why the answer is a
|
|
// walk that terminates rather than a state that cannot occur.
|
|
var resolved = HostInheritance.Resolve(
|
|
Host(port: null, username: null, groupId: Production),
|
|
Groups(
|
|
(Production, Group(parentId: Region, defaultPort: 2222)),
|
|
(Region, Group(parentId: Production, defaultUsername: "deploy"))));
|
|
|
|
// Both groups are still read once, because the repeat is what stops the walk rather than the loop
|
|
// being detected up front. What must not happen is a third visit.
|
|
resolved.Port.Value.ShouldBe(2222);
|
|
resolved.Username.Value.ShouldBe("deploy");
|
|
}
|
|
|
|
[Fact]
|
|
public void AGroupThatIsItsOwnParent_ReadsAsARoot()
|
|
{
|
|
// Not creatable through the editor and not knowable to HostGroupSecret.TryValidate, which sees the
|
|
// payload and not the item id. Contained here instead, in the same visited set that has to contain
|
|
// a longer cycle anyway.
|
|
var chain = HostInheritance
|
|
.Chain(Production, Groups((Production, Group(parentId: Production))))
|
|
.ToList();
|
|
|
|
chain.Count.ShouldBe(1);
|
|
chain[0].Id.ShouldBe(Production);
|
|
}
|
|
|
|
[Fact]
|
|
public void ACycleAboveAHost_StillResolvesEveryFieldItCanReach()
|
|
{
|
|
// The degradation the design accepts: defaults past the repeat are unresolved, everything before it
|
|
// is not, and clearing the parent in the editor is the repair. What it must never be is a connect
|
|
// that never returns.
|
|
var resolved = HostInheritance.Resolve(
|
|
Host(port: null, username: null, groupId: Production),
|
|
Groups(
|
|
(Production, Group(parentId: Region)),
|
|
(Region, Group(parentId: Production))));
|
|
|
|
resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
|
|
resolved.Username.Value.ShouldBeNull();
|
|
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheChain_YieldsNearestFirstAndStopsAtTheRoot()
|
|
{
|
|
var chain = HostInheritance
|
|
.Chain(
|
|
Production,
|
|
Groups(
|
|
(Production, Group(label: "production", parentId: Region)),
|
|
(Region, Group(label: "eu-west", parentId: Estate)),
|
|
(Estate, Group(label: "estate"))))
|
|
.ToList();
|
|
|
|
chain.Select(entry => entry.Group.Label).ShouldBe(["production", "eu-west", "estate"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheChainOfAnUngroupedHost_IsEmpty()
|
|
{
|
|
HostInheritance.Chain(null, Groups()).ShouldBeEmpty();
|
|
}
|
|
|
|
private static Dictionary<Guid, HostGroupSecret> Groups(
|
|
params (Guid Id, HostGroupSecret Group)[] groups) =>
|
|
groups.ToDictionary(entry => entry.Id, entry => entry.Group);
|
|
}
|