Files
DodoSSH/tests/DodoSSH.Client.Domain.Tests/ThreeWayMergeTests.cs
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00

276 lines
9.5 KiB
C#

namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// The merge primitives.
/// </summary>
/// <remarks>
/// These are the rules the whole sync story rests on, so they are tested as rules rather than
/// through the sync engine: every triple of (ancestor, local, remote) states is enumerated, and each
/// asserts both the surviving value and — where a side lost — that the losing value came back.
/// </remarks>
public sealed class ThreeWayMergeTests
{
// ---- Scalar ----
[Fact]
public void NeitherSideChanged_IsAgreement()
{
var merge = ThreeWayMerge.Scalar("base", "base", "base");
merge.Value.ShouldBe("base");
merge.Decision.ShouldBe(MergeDecision.Agreed);
merge.IsConflicted.ShouldBeFalse();
}
[Fact]
public void OnlyLocalChanged_KeepsTheLocalValue()
{
var merge = ThreeWayMerge.Scalar("base", "mine", "base");
merge.Value.ShouldBe("mine");
merge.Decision.ShouldBe(MergeDecision.TookLocal);
}
[Fact]
public void OnlyRemoteChanged_KeepsTheRemoteValue()
{
var merge = ThreeWayMerge.Scalar("base", "base", "theirs");
merge.Value.ShouldBe("theirs");
merge.Decision.ShouldBe(MergeDecision.TookRemote);
}
[Fact]
public void BothSidesMadeTheSameChange_IsAgreementRatherThanAConflict()
{
// Two people fixing the same typo must not be asked to arbitrate.
var merge = ThreeWayMerge.Scalar("base", "fixed", "fixed");
merge.Value.ShouldBe("fixed");
merge.Decision.ShouldBe(MergeDecision.Agreed);
merge.IsConflicted.ShouldBeFalse();
}
[Fact]
public void BothSidesChangedDifferently_TakesRemoteAndReportsLocal()
{
// Remote wins so that every replica resolves the same triple identically; without a fixed
// winner two clients each keep their own value and push over each other forever.
var merge = ThreeWayMerge.Scalar("base", "mine", "theirs");
merge.Value.ShouldBe("theirs");
merge.Decision.ShouldBe(MergeDecision.Conflicted);
merge.IsConflicted.ShouldBeTrue();
// The whole justification for picking a side: the other one is handed back, never dropped.
merge.Discarded.ShouldBe("mine");
}
[Fact]
public void AConflictedMerge_IsIdempotentOnceResolved()
{
// Convergence, spelled out. Having taken the remote value, re-merging against the same
// remote must be agreement rather than a fresh conflict — otherwise the two clients
// ping-pong.
var first = ThreeWayMerge.Scalar("base", "mine", "theirs");
var second = ThreeWayMerge.Scalar("theirs", first.Value, "theirs");
second.Decision.ShouldBe(MergeDecision.Agreed);
second.Value.ShouldBe("theirs");
}
[Fact]
public void Scalar_UsesTheSuppliedComparer()
{
// Ordinal by default would call these a conflict; the comparer is how a field opts out.
var merge = ThreeWayMerge.Scalar("base", "SAME", "same", StringComparer.OrdinalIgnoreCase);
merge.Decision.ShouldBe(MergeDecision.Agreed);
}
[Fact]
public void Scalar_HandlesNullOnAnySide()
{
// Nullable fields are the common case — Username and Notes are both optional — so a null
// must be an ordinary value here rather than a special case that throws.
ThreeWayMerge.Scalar<string?>(null, "set", null).Value.ShouldBe("set");
ThreeWayMerge.Scalar<string?>("was", null, "was").Value.ShouldBeNull();
ThreeWayMerge.Scalar<string?>(null, null, null).Decision.ShouldBe(MergeDecision.Agreed);
}
// ---- Map ----
[Fact]
public void EachSideAddedADifferentKey_KeepsBoth()
{
// The single most visible benefit of a per-key merge over comparing whole collections: two
// people adding different directives to one host both keep theirs.
var merge = Map(
ancestor: [],
local: [("Compression", "yes")],
remote: [("ServerAliveInterval", "30")]);
merge.Merged.Count.ShouldBe(2);
merge.Merged["Compression"].ShouldBe("yes");
merge.Merged["ServerAliveInterval"].ShouldBe("30");
merge.Conflicts.ShouldBeEmpty();
}
[Fact]
public void EachSideAddedTheSameKeyDifferently_TakesRemoteAndReportsLocal()
{
var merge = Map(
ancestor: [],
local: [("Port", "2222")],
remote: [("Port", "2200")]);
merge.Merged["Port"].ShouldBe("2200");
var conflict = merge.Conflicts.ShouldHaveSingleItem();
conflict.Key.ShouldBe("Port");
conflict.Kept.ShouldBe("2200");
conflict.Discarded.ShouldBe("2222");
conflict.DiscardedSide.ShouldBe(MergeSide.Local);
conflict.DiscardedWasRemoval.ShouldBeFalse();
}
[Fact]
public void OneSideRemovedAKeyTheOtherLeftAlone_RemovesIt()
{
Map(
ancestor: [("Compression", "yes")],
local: [],
remote: [("Compression", "yes")])
.Merged.ShouldBeEmpty();
Map(
ancestor: [("Compression", "yes")],
local: [("Compression", "yes")],
remote: [])
.Merged.ShouldBeEmpty();
}
[Fact]
public void RemoteEditedAKeyLocalRemoved_KeepsTheEditAndReportsTheRemoval()
{
// An edit outlives a removal in both directions. Re-applying a removal costs one click;
// a discarded value may be the only copy of something the user cannot reconstruct.
var merge = Map(
ancestor: [("Compression", "yes")],
local: [],
remote: [("Compression", "no")]);
merge.Merged["Compression"].ShouldBe("no");
var conflict = merge.Conflicts.ShouldHaveSingleItem();
conflict.DiscardedSide.ShouldBe(MergeSide.Local);
conflict.DiscardedWasRemoval.ShouldBeTrue();
conflict.Kept.ShouldBe("no");
}
[Fact]
public void LocalEditedAKeyRemoteRemoved_KeepsTheEditAndReportsTheRemoval()
{
var merge = Map(
ancestor: [("Compression", "yes")],
local: [("Compression", "no")],
remote: []);
merge.Merged["Compression"].ShouldBe("no");
var conflict = merge.Conflicts.ShouldHaveSingleItem();
// The overridden side is the remote one here, which is what makes this asymmetric from the
// scalar rule: the tie-break is "a value beats an absence" before it is "remote wins".
conflict.DiscardedSide.ShouldBe(MergeSide.Remote);
conflict.DiscardedWasRemoval.ShouldBeTrue();
}
[Fact]
public void BothSidesRemovedTheSameKey_IsAgreement()
{
var merge = Map(
ancestor: [("Compression", "yes")],
local: [],
remote: []);
merge.Merged.ShouldBeEmpty();
merge.Conflicts.ShouldBeEmpty();
}
[Fact]
public void UnchangedKeys_SurviveAlongsideConflictingOnes()
{
// A conflict on one key must not disturb its neighbours, which is the difference between
// field-level merge and replacing the collection.
var merge = Map(
ancestor: [("Keep", "same"), ("Fight", "base")],
local: [("Keep", "same"), ("Fight", "mine")],
remote: [("Keep", "same"), ("Fight", "theirs")]);
merge.Merged["Keep"].ShouldBe("same");
merge.Merged["Fight"].ShouldBe("theirs");
merge.Conflicts.ShouldHaveSingleItem().Key.ShouldBe("Fight");
}
[Fact]
public void Map_TreatsKeysUnderTheSuppliedComparer()
{
// SSH keywords are case-insensitive. Treating these as two keys would let a host carry
// both Compression and compression, which no client could then reconcile.
var merge = Map(
ancestor: [("Compression", "yes")],
local: [("compression", "yes")],
remote: [("COMPRESSION", "yes")]);
merge.Merged.Count.ShouldBe(1);
merge.Conflicts.ShouldBeEmpty();
}
[Fact]
public void Map_NeverDropsAValueWithoutReportingIt()
{
// The invariant, asserted directly rather than inferred from the cases above: every value
// present on either side either survives into the merge or appears in the conflict list.
var local = new[] { ("A", "1"), ("B", "2"), ("C", "3") };
var remote = new[] { ("A", "9"), ("B", "2"), ("D", "4") };
var merge = Map(ancestor: [("A", "0"), ("B", "2")], local: local, remote: remote);
foreach (var (key, value) in local.Concat(remote))
{
var survived = merge.Merged.TryGetValue(key, out var kept)
&& string.Equals(kept, value, StringComparison.Ordinal);
var reported = merge.Conflicts.Any(c =>
HostOption.NameComparer.Equals(c.Key, key)
&& string.Equals(c.Discarded, value, StringComparison.Ordinal));
(survived || reported).ShouldBeTrue($"{key}={value} was neither kept nor reported.");
}
}
private static MapMerge<string, string> Map(
(string Key, string Value)[] ancestor,
(string Key, string Value)[] local,
(string Key, string Value)[] remote) =>
ThreeWayMerge.Map(
ToMap(ancestor),
ToMap(local),
ToMap(remote),
HostOption.NameComparer,
StringComparer.Ordinal);
private static Dictionary<string, string> ToMap((string Key, string Value)[] entries)
{
var map = new Dictionary<string, string>(HostOption.NameComparer);
foreach (var (key, value) in entries)
{
map[key] = value;
}
return map;
}
}