Files
DodoSSH/tests/DodoSSH.Client.Domain.Tests/HostSecretMergeTests.cs
T
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

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