Public Access
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing on either head has changed, which is deliberate — the plan orders these first because everything the editors will bind to has to exist and be merge-safe before a screen can offer it. HostGroupSecret gains a parent and four defaults, and the codec gains the version rule it never had. It stamped CurrentSchemaVersion unconditionally, which was harmless with one field and one version and stops being harmless here: upgrading one machine and renaming any group would have made that group uneditable on every machine still on the old build. It now emits the lowest version that loses nothing, so a flat group with no defaults still encodes at version 1, byte for byte, pinned against a literal. Tags become a real item over the reserved slot. Secret, codec, merge, cipher, repository, both registries, the EF entity and a generated AddTagItem migration. TagCipher names AadResourceType.Tag as a constant rather than casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum and 5 there is Credential — a cast would seal every tag under the resource type for a password, encrypt and decrypt perfectly on the machine that wrote it, and only fail when another implementation refused the item, by which time the AAD is frozen into stored ciphertext. HostTag stays reserved and unused: the one thing the join buys over a set on the host is bought instead by merging TagIds per id. HostSecret grows TagIds and Port goes nullable, which is the change with the widest blast radius and the only one that loses an item rather than locking one. A host with no port of its own omits the property, an older build reads int Port as 0, and TryValidate refuses it — unreadable rather than read-only. That cost is confined to hosts which actually inherit, because the version is a maximum over the fields present; the alternative, writing 22 into every host, is the lie inheritance exists to stop telling. One decision the plan did not specify. "Three states where there were two" is four — key, credential, typed password, or the group's answer — and two nullable ids carry three. Naming neither id now means inherit, so AsksForPassword says "a typed password even under a group that lends a key" out loud. Only true is ever written and a decoded false folds back to null, so a host that never touched it encodes as it always did. Nothing already stored changed meaning: no group could lend a binding before this build, so every existing host resolves exactly as it did. HostInheritance is the resolver, and its visited set is load-bearing rather than defensive. Two clients can each re-parent A under B and B under A while offline; the merge sees one item against one item and the server sees ciphertext, so nothing upstream can refuse the pair. With inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable sidebar — it is a shell that never opens. Stopping at the first repeat degrades it to a group that reads as a root, and clearing the parent is the repair. A tag set turns out to be the one field on a host that can never ask the user anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so the both-sides-moved-differently branch of the keyed merge is unreachable — asserted over the whole eight-row matrix. The conflict loop is kept anyway, because that proof is one edit from ceasing to hold and what it would cause is a discarded tag nothing records. Three guard tests failed by design and were fixed rather than relaxed: the ordered pull filter, the AAD pinning table, and the server's refusal of a plaintext parent — that last one survives with its reason rewritten, because the refusal now means "the parent is not the server's to hold" rather than "there is no such thing as a parent". The prose that said groups are flat is rewritten in all four places it appeared, not deleted. The five view-model sites that read Port directly now go through the resolver, which is a down payment on step 4 rather than the whole of it. HostFields.From still emits the stored port, and that is the one remaining place where an unresolved read would be a wrong wire rather than a wrong label. Verified by the whole suite: 1382 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display, because nothing on a display has changed yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
388 lines
15 KiB
C#
388 lines
15 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]),
|
|
};
|
|
|
|
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 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);
|
|
}
|
|
}
|