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);
}