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, }; var result = HostSecretMerge.Merge(ancestor, local, ancestor); result.Merged.ShouldBe(local); result.HasConflicts.ShouldBeFalse(); } [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); } }