using static DodoSSH.Client.Domain.Tests.HostFactory; namespace DodoSSH.Client.Domain.Tests; /// /// Merging a host field by field. /// /// /// The primitives are covered by ; this is about the wiring — that /// every field is actually routed through a merge, that the collections use the right strategy, and /// that a conflict names the field precisely enough for a user to act on it. /// public sealed class HostSecretMergeTests { [Fact] public void NeitherSideChanged_ProducesTheSameHostAndNoConflicts() { var host = Host(); var result = HostSecretMerge.Merge(host, host, host); result.Merged.ShouldBe(host); result.HasConflicts.ShouldBeFalse(); } [Fact] public void EachSideChangedADifferentField_BothSurvive() { // The reason a field-level merge is worth writing at all. var ancestor = Host(); var local = ancestor with { Notes = "rotate quarterly" }; var remote = ancestor with { Username = "postgres" }; var result = HostSecretMerge.Merge(ancestor, local, remote); result.Merged.Notes.ShouldBe("rotate quarterly"); result.Merged.Username.ShouldBe("postgres"); result.HasConflicts.ShouldBeFalse(); } [Fact] public void EveryScalarField_IsRoutedThroughAMerge() { // A field added to HostSecret but forgotten in the merge would silently revert to the remote // value forever. Changing each one only locally proves each is actually consulted. var ancestor = Host(); var local = ancestor with { Label = "prod-db-1", Hostname = "db1.internal", Port = 2222, Username = "admin", Notes = "primary", JumpHostIds = JumpChain.Create([Bastion]), Options = HostOptions.Create([new HostOption("Compression", "yes")]), RelayEnabled = true, SshKeyId = DeployKey, GroupId = Production, TagIds = TagSet.Create([Pci]), PinnedPaths = PinnedPathList.Create(["/var/www/app"]), }; var result = HostSecretMerge.Merge(ancestor, local, ancestor); result.Merged.ShouldBe(local); result.HasConflicts.ShouldBeFalse(); } [Fact] public void TheFieldsTheExclusionsKeepOutOfTheGuardAbove_AreAlsoRoutedThroughAMerge() { // AsksForPassword cannot sit beside SshKeyId in one valid host, and a null Port cannot sit beside // an explicit one — so both get their own pass rather than being the fields the guard silently // skips. A forgotten one here means a host put back on a typed password, or set to take its group's // port, quietly reverting to the server's copy for ever. var ancestor = Host(); var local = ancestor with { Port = null, AsksForPassword = true }; var result = HostSecretMerge.Merge(ancestor, local, ancestor); result.Merged.ShouldBe(local); result.HasConflicts.ShouldBeFalse(); } [Fact] public void TwoPeopleAddingDifferentTagsToOneHost_BothKeepTheirs() { // The single most visible difference between a field-level merge and last-writer-wins, and the // reason TagIds merges per tag rather than as a whole value. A whole-value merge would take one // side's set entire and drop the other's. var ancestor = Host(); var result = HostSecretMerge.Merge( ancestor, ancestor with { TagIds = TagSet.Create([Pci]) }, ancestor with { TagIds = TagSet.Create([EuWest]) }); result.Merged.TagIds.ShouldBe(TagSet.Create([Pci, EuWest])); result.HasConflicts.ShouldBeFalse(); } [Fact] public void OneSideRemovingATagWhileTheOtherAddsAnother_KeepsBothDecisions() { // Each tag is resolved on its own, so a removal on one side and an addition on the other are two // independent answers rather than two versions of one. A whole-value merge would have to pick. var ancestor = Host(tags: [Pci]); var result = HostSecretMerge.Merge( ancestor, ancestor with { TagIds = TagSet.Empty }, ancestor with { TagIds = TagSet.Create([Pci, EuWest]) }); result.Merged.TagIds.ShouldBe(TagSet.Create([EuWest])); result.HasConflicts.ShouldBeFalse(); } [Fact] public void ATagRemovedOnBothSides_IsNotResurrected() { var ancestor = Host(tags: [Pci, EuWest]); var untagged = ancestor with { TagIds = TagSet.Create([EuWest]) }; var result = HostSecretMerge.Merge(ancestor, untagged, untagged); result.Merged.TagIds.ShouldBe(TagSet.Create([EuWest])); result.HasConflicts.ShouldBeFalse(); } /// /// The property that makes a tag set the one field on a host which can never ask the user anything. A /// tag is present or absent, so a key cannot hold two values, so the "both sides moved differently" /// branch of the keyed merge is unreachable — see HostSecretMerge.MergeTags. Stated as a table /// over every arrangement of one tag, because the claim is about the whole matrix rather than about any /// one row of it. /// [Theory] [InlineData(true, true, true)] [InlineData(true, true, false)] [InlineData(true, false, true)] [InlineData(true, false, false)] [InlineData(false, true, true)] [InlineData(false, true, false)] [InlineData(false, false, true)] [InlineData(false, false, false)] public void NoArrangementOfOneTag_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote) { var result = HostSecretMerge.Merge( Host(tags: Wearing(inAncestor)), Host(tags: Wearing(inLocal)), Host(tags: Wearing(inRemote))); result.HasConflicts.ShouldBeFalse(); // And the outcome is the one a set should give: a side that moved gets its way, because the other // one did not move. result.Merged.TagIds.Contains(Pci).ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote); } private static Guid[] Wearing(bool tagged) => tagged ? [Pci] : []; [Fact] public void TwoPeoplePinningDifferentPathsToOneHost_BothKeepTheirs() { // The same reason TagIds merges per tag rather than as a whole value: a whole-value merge would // take one side's list entire and drop the other's. var ancestor = Host(); var result = HostSecretMerge.Merge( ancestor, ancestor with { PinnedPaths = PinnedPathList.Create(["/var/www/app"]) }, ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) }); result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"])); result.HasConflicts.ShouldBeFalse(); } [Fact] public void PinnedPathAdditions_ComeOutInBaseOrderThenLocalThenRemote() { // The order guarantee this merge exists to keep: unlike a tag chip, a pinned path's position is // part of what the user set, so the merge must produce a deterministic order rather than whatever // a set union happens to yield. var ancestor = Host(pinnedPaths: ["/base/one", "/base/two"]); var result = HostSecretMerge.Merge( ancestor, ancestor with { PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/local/added"]), }, ancestor with { PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/remote/added"]), }); result.Merged.PinnedPaths.ShouldBe( PinnedPathList.Create(["/base/one", "/base/two", "/local/added", "/remote/added"])); result.HasConflicts.ShouldBeFalse(); } [Fact] public void OneSideRemovingAPinnedPathWhileTheOtherAddsAnother_KeepsBothDecisions() { // Each path is resolved on its own, so a removal on one side and an addition on the other are two // independent answers rather than two versions of one. A whole-value merge would have to pick. var ancestor = Host(pinnedPaths: ["/var/www/app"]); var result = HostSecretMerge.Merge( ancestor, ancestor with { PinnedPaths = PinnedPathList.Empty }, ancestor with { PinnedPaths = PinnedPathList.Create(["/var/www/app", "/var/log"]), }); result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"])); result.HasConflicts.ShouldBeFalse(); } [Fact] public void APinnedPathRemovedOnBothSides_IsNotResurrected() { var ancestor = Host(pinnedPaths: ["/var/www/app", "/var/log"]); var withoutApp = ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) }; var result = HostSecretMerge.Merge(ancestor, withoutApp, withoutApp); result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"])); result.HasConflicts.ShouldBeFalse(); } /// [Theory] [InlineData(true, true, true)] [InlineData(true, true, false)] [InlineData(true, false, true)] [InlineData(true, false, false)] [InlineData(false, true, true)] [InlineData(false, true, false)] [InlineData(false, false, true)] [InlineData(false, false, false)] public void NoArrangementOfOnePinnedPath_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote) { var result = HostSecretMerge.Merge( Host(pinnedPaths: Pinning(inAncestor)), Host(pinnedPaths: Pinning(inLocal)), Host(pinnedPaths: Pinning(inRemote))); result.HasConflicts.ShouldBeFalse(); result.Merged.PinnedPaths.Contains("/var/www/app") .ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote); } private static string[] Pinning(bool pinned) => pinned ? ["/var/www/app"] : []; [Fact] public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict() { // The formatter has to run for the null side, and null here is not an absence — it is the decision // to take the group's port. A conflict log printing an empty string in its place would leave the // user unable to tell which of the two decisions was dropped. var ancestor = Host(port: 22); var result = HostSecretMerge.Merge( ancestor, ancestor with { Port = null }, ancestor with { Port = 2222 }); result.Merged.Port.ShouldBe(2222); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(HostSecret.Port)); conflict.Kept.ShouldBe("2222"); conflict.Discarded.ShouldBe("the group's port"); } [Fact] public void ARemovedKeyBinding_IsNotResurrectedByTheOtherSide() { // The other direction, and the one a two-way diff gets wrong: null is a value here, not an absence. // A host deliberately put back on a password must not silently regain its key because the server's // copy still names one. var ancestor = Host(sshKeyId: DeployKey); var local = ancestor with { SshKeyId = null }; var result = HostSecretMerge.Merge(ancestor, local, ancestor); result.Merged.SshKeyId.ShouldBeNull(); result.HasConflicts.ShouldBeFalse(); } [Fact] public void TwoSidesBindingDifferentKeys_NamesBothIdsInTheConflict() { // An id is not a secret — it names a vault item rather than being the key — so both are shown. The // user cannot tell which of two keys was dropped otherwise. var other = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e04"); var ancestor = Host(); var local = ancestor with { SshKeyId = DeployKey }; var remote = ancestor with { SshKeyId = other }; var result = HostSecretMerge.Merge(ancestor, local, remote); result.Merged.SshKeyId.ShouldBe(other); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(HostSecret.SshKeyId)); conflict.Kept.ShouldBe(other.ToString()); conflict.Discarded.ShouldBe(DeployKey.ToString()); } [Fact] public void ABindingClashingWithItsRemoval_SaysWhichSideHadNoKey() { // "no key" rather than a blank, for the same reason a clashing port is reported as a number: a // conflict entry whose discarded value is empty reads as a bug in the conflict log. var ancestor = Host(sshKeyId: DeployKey); var local = ancestor with { SshKeyId = null }; var remote = ancestor with { SshKeyId = Relay }; var result = HostSecretMerge.Merge(ancestor, local, remote); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(HostSecret.SshKeyId)); conflict.Kept.ShouldBe(Relay.ToString()); conflict.Discarded.ShouldBe("no key"); } [Fact] public void AClashingScalar_TakesRemoteAndNamesTheFieldItDiscarded() { var ancestor = Host(); var local = ancestor with { Hostname = "db-mine.internal" }; var remote = ancestor with { Hostname = "db-theirs.internal" }; var result = HostSecretMerge.Merge(ancestor, local, remote); result.Merged.Hostname.ShouldBe("db-theirs.internal"); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(HostSecret.Hostname)); conflict.Kept.ShouldBe("db-theirs.internal"); conflict.Discarded.ShouldBe("db-mine.internal"); conflict.DiscardedSide.ShouldBe(MergeSide.Local); } [Fact] public void AClashingPort_IsReportedAsANumberNotAsBlank() { // Rendering the losing value is the entire point of the conflict record; a non-string field // that formatted to nothing would leave the user unable to restore it. var ancestor = Host(port: 22); var result = HostSecretMerge.Merge(ancestor, ancestor with { Port = 2222 }, ancestor with { Port = 2200 }); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(HostSecret.Port)); conflict.Kept.ShouldBe("2200"); conflict.Discarded.ShouldBe("2222"); } [Fact] public void AJumpChain_MergesAsAWholeRouteRatherThanAsASet() { // Deliberate, and the opposite of how the directives merge. Unioning two chains would // produce a route neither user configured and would silently change which machine is // reached through which — so this conflicts instead, and reports the discarded route. var ancestor = Host(); var local = ancestor with { JumpHostIds = JumpChain.Create([Bastion]) }; var remote = ancestor with { JumpHostIds = JumpChain.Create([Relay]) }; var result = HostSecretMerge.Merge(ancestor, local, remote); result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay])).ShouldBeTrue(); result.Merged.JumpHostIds.Count.ShouldBe(1); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(HostSecret.JumpHostIds)); conflict.Discarded.ShouldNotBeNull(); conflict.Discarded.ShouldContain(Bastion.ToString()); } [Fact] public void AReorderedJumpChain_IsAChange() { var ancestor = Host(jumps: [Bastion, Relay]); var local = ancestor with { JumpHostIds = JumpChain.Create([Relay, Bastion]) }; var result = HostSecretMerge.Merge(ancestor, local, ancestor); result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay, Bastion])).ShouldBeTrue(); } [Fact] public void Directives_MergePerNameSoBothAdditionsSurvive() { var ancestor = Host(); var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "yes")]) }; var remote = ancestor with { Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]), }; var result = HostSecretMerge.Merge(ancestor, local, remote); result.Merged.Options.Count.ShouldBe(2); result.Merged.Options.TryGetValue("Compression", out var compression).ShouldBeTrue(); compression.ShouldBe("yes"); result.Merged.Options.TryGetValue("ServerAliveInterval", out var keepAlive).ShouldBeTrue(); keepAlive.ShouldBe("30"); result.HasConflicts.ShouldBeFalse(); } [Fact] public void AClashingDirective_NamesTheDirectiveNotJustTheField() { // "Options changed" would be useless. The user needs to know which one. var ancestor = Host(options: [("Compression", "yes")]); var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) }; var remote = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "delayed")]), }; var result = HostSecretMerge.Merge(ancestor, local, remote); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe("Options[Compression]"); conflict.Kept.ShouldBe("delayed"); conflict.Discarded.ShouldBe("no"); } [Fact] public void ARemovedDirectiveTheOtherSideEdited_KeepsTheValue() { var ancestor = Host(options: [("Compression", "yes")]); var local = ancestor with { Options = HostOptions.Empty }; var remote = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) }; var result = HostSecretMerge.Merge(ancestor, local, remote); result.Merged.Options.TryGetValue("Compression", out var value).ShouldBeTrue(); value.ShouldBe("no"); result.Conflicts.ShouldHaveSingleItem().DiscardedWasRemoval.ShouldBeTrue(); } [Fact] public void TheMergedHost_IsAlwaysValidWhenBothInputsWere() { // A merge that produced an unstorable host would strand the item: it could never be pushed // and the conflict could never clear. var ancestor = Host(); var local = ancestor with { Label = "mine", Port = 2222 }; var remote = ancestor with { Label = "theirs", Hostname = "other.internal" }; var result = HostSecretMerge.Merge(ancestor, local, remote); result.Merged.TryValidate(out var error).ShouldBeTrue(error); } [Fact] public void ResolvingAConflictConverges() { // Two clients, both merging, must reach the same host and then stop. Re-merging the result // against the remote produces no further conflict — which is what stops an endless // push-conflict-merge-push loop between two machines. var ancestor = Host(); var local = ancestor with { Notes = "mine", Username = "a" }; var remote = ancestor with { Notes = "theirs", Hostname = "other.internal" }; var first = HostSecretMerge.Merge(ancestor, local, remote); first.HasConflicts.ShouldBeTrue(); var second = HostSecretMerge.Merge(remote, first.Merged, remote); second.HasConflicts.ShouldBeFalse(); second.Merged.ShouldBe(first.Merged); } }