Public Access
485 lines
19 KiB
C#
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);
|
|
}
|
|
}
|