using static DodoSSH.Client.Domain.Tests.HostFactory; namespace DodoSSH.Client.Domain.Tests; /// /// Walking a host's group chain for the values it did not state itself. /// /// /// /// 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. /// /// /// 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. /// /// 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 Groups( params (Guid Id, HostGroupSecret Group)[] groups) => groups.ToDictionary(entry => entry.Id, entry => entry.Group); }