using static DodoSSH.Client.Domain.Tests.HostFactory; namespace DodoSSH.Client.Domain.Tests; /// /// Equality of the collection types, and of the host that holds them. /// /// /// This suite guards a failure that would be invisible rather than loud. If any of these compared by /// reference, the merge would report every host as changed on every sync pass, two identical edits /// would register as a conflict, and the engine would push spurious updates forever. Nothing would /// throw and no test elsewhere would obviously fail — which is exactly why these are asserted here. /// public sealed class ValueSemanticsTests { [Fact] public void TwoHostsWithEqualContents_AreEqual() { var one = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]); var other = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]); one.ShouldBe(other); one.GetHashCode().ShouldBe(other.GetHashCode()); } [Fact] public void AHostDifferingOnlyInACollection_IsNotEqual() { Host(jumps: [Bastion]).ShouldNotBe(Host(jumps: [Relay])); Host(options: [("Compression", "yes")]).ShouldNotBe(Host(options: [("Compression", "no")])); } // Note on the assertion style below: these call Equals and the operators directly rather than // going through ShouldBe. Both of these types implement IReadOnlyList, and Shouldly compares // enumerables element by element — so ShouldBe would pass whatever Equals did, which is the one // thing this suite exists to check. [Fact] public void AJumpChain_ComparesByContentsAndOrder() { JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeTrue(); (JumpChain.Create([Bastion, Relay]) == JumpChain.Create([Bastion, Relay])).ShouldBeTrue(); JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Relay, Bastion])).ShouldBeFalse(); JumpChain.Create([Bastion]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeFalse(); JumpChain.Create([]).Equals(JumpChain.Empty).ShouldBeTrue(); JumpChain.Create([Bastion]).Equals(null).ShouldBeFalse(); } [Fact] public void AJumpChain_HashesByContents() { JumpChain.Create([Bastion, Relay]).GetHashCode() .ShouldBe(JumpChain.Create([Bastion, Relay]).GetHashCode()); } [Fact] public void AJumpChain_ComparesEqualAcrossTheSpanAndSequenceFactories() { Guid[] hops = [Bastion, Relay]; JumpChain.Create(hops.AsSpan()).Equals(JumpChain.Create(hops.AsEnumerable())).ShouldBeTrue(); } [Fact] public void Directives_CompareIgnoringNameCaseAndInputOrder() { // Both halves matter. Case, because a merge picks whichever spelling it saw first and two // clients must still agree. Order, because the collection canonicalises and a user typing // the same two directives in the other order has not changed anything. var one = HostOptions.Create([new HostOption("Compression", "yes"), new HostOption("Port", "22")]); var other = HostOptions.Create([new HostOption("port", "22"), new HostOption("compression", "yes")]); one.Equals(other).ShouldBeTrue(); (one == other).ShouldBeTrue(); one.GetHashCode().ShouldBe(other.GetHashCode()); } [Fact] public void Directives_CompareValuesCaseSensitively() { // Keywords are case-insensitive in SSH; values are not. "yes" and "YES" happen to mean the // same to sshd, but this layer must not decide that for every directive that exists. HostOptions.Create([new HostOption("Compression", "yes")]) .Equals(HostOptions.Create([new HostOption("Compression", "YES")])) .ShouldBeFalse(); } [Fact] public void Directives_CompareUnequalWhenOneSideHasMore() { var one = HostOptions.Create([new HostOption("Compression", "yes")]); var other = HostOptions.Create( [new HostOption("Compression", "yes"), new HostOption("Port", "22")]); one.Equals(other).ShouldBeFalse(); HostOptions.Empty.Equals(one).ShouldBeFalse(); one.Equals(null).ShouldBeFalse(); } [Fact] public void Directives_AreHeldInNameOrder() { var options = HostOptions.Create( [ new HostOption("ServerAliveInterval", "30"), new HostOption("Compression", "yes"), ]); options[0].Name.ShouldBe("Compression"); options[1].Name.ShouldBe("ServerAliveInterval"); } [Fact] public void ARepeatedDirectiveName_IsRefused() { // A repeated keyword has no merge key, so M1 cannot represent it. Refusing is the honest // answer; silently keeping one of the two would lose data without saying so. var duplicate = new[] { new HostOption("Compression", "yes"), new HostOption("compression", "no"), }; HostOptions.TryCreate(duplicate, out _, out var error).ShouldBeFalse(); error.ShouldNotBeNull(); error.Contains("more than once", StringComparison.Ordinal).ShouldBeTrue(); Should.Throw(() => HostOptions.Create(duplicate)); } [Fact] public void ABlankDirectiveName_IsRefused() { HostOptions.TryCreate([new HostOption(" ", "x")], out _, out _).ShouldBeFalse(); } [Fact] public void AHostBuiltWithWith_KeepsCollectionEquality() { // `with` copies the collection references, so this would pass even under reference equality. // It is here because the merge builds its result with an object initialiser rather than // `with`, and both paths have to agree. var host = Host(options: [("Compression", "yes")]); var copy = host with { Notes = "changed" }; copy.Options.Equals(host.Options).ShouldBeTrue(); (copy with { Notes = host.Notes }).ShouldBe(host); } [Fact] public void ATagSet_ComparesByContentsAndNotByOrder() { // The half that differs from a jump chain, and the reason it is a separate type. A route reordered // is a different route; a tag list reordered is the same host, so two users who tapped the same two // chips in opposite orders must produce one value and nothing to push. TagSet.Create([Pci, EuWest]).Equals(TagSet.Create([EuWest, Pci])).ShouldBeTrue(); (TagSet.Create([Pci, EuWest]) == TagSet.Create([EuWest, Pci])).ShouldBeTrue(); TagSet.Create([Pci, EuWest]).GetHashCode() .ShouldBe(TagSet.Create([EuWest, Pci]).GetHashCode()); TagSet.Create([Pci]).Equals(TagSet.Create([EuWest])).ShouldBeFalse(); TagSet.Create([Pci]).Equals(TagSet.Create([Pci, EuWest])).ShouldBeFalse(); TagSet.Create([]).Equals(TagSet.Empty).ShouldBeTrue(); TagSet.Create([Pci]).Equals(null).ShouldBeFalse(); } [Fact] public void ATagSet_CollapsesARepeatedTag() { // A repeat arrives from a payload some other client wrote. Left alone it would compare unequal to // the same set written once, and the engine would push the host as changed on every pass for ever. TagSet.Create([Pci, Pci]).Equals(TagSet.Create([Pci])).ShouldBeTrue(); TagSet.Create([Pci, Pci]).Count.ShouldBe(1); } [Fact] public void ATagSet_MapsToItsOwnIdsSoAKeyedMergeIsASetMerge() { // The value repeats the key on purpose: no key can then hold two different values, so the only // disagreement a keyed merge can report is one side adding what the other removed. var map = TagSet.Create([Pci, EuWest]).ToIdMap(); map.Keys.Order().ShouldBe(new[] { Pci, EuWest }.Order()); map[Pci].ShouldBe(Pci); map[EuWest].ShouldBe(EuWest); } [Fact] public void TryValidate_RejectsWhatCannotBeStored() { Host(label: "").TryValidate(out _).ShouldBeFalse(); Host(hostname: " ").TryValidate(out _).ShouldBeFalse(); Host(port: 0).TryValidate(out _).ShouldBeFalse(); Host(port: 65536).TryValidate(out _).ShouldBeFalse(); Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); Host(tags: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); // Null is "take the group's port", not an absent one, and it has to be storable — it is the whole // of what inheritance stores. Host(port: null).TryValidate(out _).ShouldBeTrue(); Host().TryValidate(out _).ShouldBeTrue(); } [Fact] public void AHostGivesOneAnswerAboutAuthentication_NotTwo() { // The same failure the key-or-credential rule catches, in the direction inheritance opened: a host // that names a key and also says "ask me for a password" has answered one question twice, and the // interface, the connect path and the user would each be free to pick a different answer. var both = Host(sshKeyId: DeployKey, asksForPassword: true); both.TryValidate(out var reason).ShouldBeFalse(); reason.ShouldNotBeNull().ShouldContain("not both"); Host(asksForPassword: true).TryValidate(out _).ShouldBeTrue(); Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue(); } [Fact] public void AHostAuthenticatesOneWay_NotTwo() { // SSH would happily try a key and fall back to a password, and a host that named both would leave // "how does this authenticate?" without a single answer — so the interface, the connect path and the // user would each be free to guess differently. Refused at the type instead. var both = Host(sshKeyId: DeployKey, credentialId: Guid.CreateVersion7()); both.TryValidate(out var reason).ShouldBeFalse(); reason.ShouldNotBeNull().ShouldContain("not both"); Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue(); Host(credentialId: Guid.CreateVersion7()).TryValidate(out _).ShouldBeTrue(); } }