Files

485 lines
19 KiB
C#

using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// Merging a host field by field.
/// </summary>
/// <remarks>
/// The primitives are covered by <see cref="ThreeWayMergeTests"/>; 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.
/// </remarks>
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();
}
/// <remarks>
/// 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 <c>HostSecretMerge.MergeTags</c>. 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.
/// </remarks>
[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();
}
/// <inheritdoc cref="NoArrangementOfOneTag_ProducesAConflict" />
[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);
}
}