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

162 lines
6.3 KiB
C#

using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// Equality of the collection types, and of the host that holds them.
/// </summary>
/// <remarks>
/// This suite guards a failure that would be invisible rather than loud. If any of these compared by
/// reference, the merge would report every host as changed on every sync pass, two identical edits
/// would register as a conflict, and the engine would push spurious updates forever. Nothing would
/// throw and no test elsewhere would obviously fail — which is exactly why these are asserted here.
/// </remarks>
public sealed class ValueSemanticsTests
{
[Fact]
public void TwoHostsWithEqualContents_AreEqual()
{
var one = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
var other = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
one.ShouldBe(other);
one.GetHashCode().ShouldBe(other.GetHashCode());
}
[Fact]
public void AHostDifferingOnlyInACollection_IsNotEqual()
{
Host(jumps: [Bastion]).ShouldNotBe(Host(jumps: [Relay]));
Host(options: [("Compression", "yes")]).ShouldNotBe(Host(options: [("Compression", "no")]));
}
// Note on the assertion style below: these call Equals and the operators directly rather than
// going through ShouldBe. Both of these types implement IReadOnlyList, and Shouldly compares
// enumerables element by element — so ShouldBe would pass whatever Equals did, which is the one
// thing this suite exists to check.
[Fact]
public void AJumpChain_ComparesByContentsAndOrder()
{
JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
(JumpChain.Create([Bastion, Relay]) == JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Relay, Bastion])).ShouldBeFalse();
JumpChain.Create([Bastion]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeFalse();
JumpChain.Create([]).Equals(JumpChain.Empty).ShouldBeTrue();
JumpChain.Create([Bastion]).Equals(null).ShouldBeFalse();
}
[Fact]
public void AJumpChain_HashesByContents()
{
JumpChain.Create([Bastion, Relay]).GetHashCode()
.ShouldBe(JumpChain.Create([Bastion, Relay]).GetHashCode());
}
[Fact]
public void AJumpChain_ComparesEqualAcrossTheSpanAndSequenceFactories()
{
Guid[] hops = [Bastion, Relay];
JumpChain.Create(hops.AsSpan()).Equals(JumpChain.Create(hops.AsEnumerable())).ShouldBeTrue();
}
[Fact]
public void Directives_CompareIgnoringNameCaseAndInputOrder()
{
// Both halves matter. Case, because a merge picks whichever spelling it saw first and two
// clients must still agree. Order, because the collection canonicalises and a user typing
// the same two directives in the other order has not changed anything.
var one = HostOptions.Create([new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
var other = HostOptions.Create([new HostOption("port", "22"), new HostOption("compression", "yes")]);
one.Equals(other).ShouldBeTrue();
(one == other).ShouldBeTrue();
one.GetHashCode().ShouldBe(other.GetHashCode());
}
[Fact]
public void Directives_CompareValuesCaseSensitively()
{
// Keywords are case-insensitive in SSH; values are not. "yes" and "YES" happen to mean the
// same to sshd, but this layer must not decide that for every directive that exists.
HostOptions.Create([new HostOption("Compression", "yes")])
.Equals(HostOptions.Create([new HostOption("Compression", "YES")]))
.ShouldBeFalse();
}
[Fact]
public void Directives_CompareUnequalWhenOneSideHasMore()
{
var one = HostOptions.Create([new HostOption("Compression", "yes")]);
var other = HostOptions.Create(
[new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
one.Equals(other).ShouldBeFalse();
HostOptions.Empty.Equals(one).ShouldBeFalse();
one.Equals(null).ShouldBeFalse();
}
[Fact]
public void Directives_AreHeldInNameOrder()
{
var options = HostOptions.Create(
[
new HostOption("ServerAliveInterval", "30"),
new HostOption("Compression", "yes"),
]);
options[0].Name.ShouldBe("Compression");
options[1].Name.ShouldBe("ServerAliveInterval");
}
[Fact]
public void ARepeatedDirectiveName_IsRefused()
{
// A repeated keyword has no merge key, so M1 cannot represent it. Refusing is the honest
// answer; silently keeping one of the two would lose data without saying so.
var duplicate = new[]
{
new HostOption("Compression", "yes"),
new HostOption("compression", "no"),
};
HostOptions.TryCreate(duplicate, out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
error.Contains("more than once", StringComparison.Ordinal).ShouldBeTrue();
Should.Throw<ArgumentException>(() => HostOptions.Create(duplicate));
}
[Fact]
public void ABlankDirectiveName_IsRefused()
{
HostOptions.TryCreate([new HostOption(" ", "x")], out _, out _).ShouldBeFalse();
}
[Fact]
public void AHostBuiltWithWith_KeepsCollectionEquality()
{
// `with` copies the collection references, so this would pass even under reference equality.
// It is here because the merge builds its result with an object initialiser rather than
// `with`, and both paths have to agree.
var host = Host(options: [("Compression", "yes")]);
var copy = host with { Notes = "changed" };
copy.Options.Equals(host.Options).ShouldBeTrue();
(copy with { Notes = host.Notes }).ShouldBe(host);
}
[Fact]
public void TryValidate_RejectsWhatCannotBeStored()
{
Host(label: "").TryValidate(out _).ShouldBeFalse();
Host(hostname: " ").TryValidate(out _).ShouldBeFalse();
Host(port: 65536).TryValidate(out _).ShouldBeFalse();
Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
Host().TryValidate(out _).ShouldBeTrue();
}
}