Files
DodoSSH/tests/DodoSSH.Contracts.Tests/SerializationTests.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

231 lines
8.0 KiB
C#

using System.Text.Json;
using DodoSSH.Contracts;
namespace DodoSSH.Contracts.Tests;
/// <summary>
/// Wire-format behaviour of the shared contracts.
/// </summary>
/// <remarks>
/// These assert the properties the client depends on and that a compile-time reference cannot
/// guarantee: camelCase naming, enums as strings, base64 for binary, and strict rejection of
/// unmapped members on inbound requests.
/// </remarks>
public sealed class SerializationTests
{
// Deliberately the shared options, not a hand-rolled instance: constructing options
// separately is exactly the mistake these contracts must not permit.
private static JsonSerializerOptions Options => DodoSshJsonContext.ResponseOptions;
[Fact]
public void Properties_AreCamelCase()
{
var payload = new EncryptedPayload(
[1, 2, 3],
WrappedDataKey: [4, 5],
DataKeyId: Guid.CreateVersion7(),
KeyGeneration: 4,
AadVersion: 1);
var json = JsonSerializer.Serialize(payload, Options);
json.ShouldContain("\"envelope\"");
json.ShouldContain("\"wrappedDataKey\"");
json.ShouldContain("\"dataKeyId\"");
json.ShouldContain("\"keyGeneration\"");
json.ShouldContain("\"aadVersion\"");
}
[Fact]
public void ByteArrays_AreBase64()
{
var payload = new EncryptedPayload(
[0xDE, 0xAD, 0xBE, 0xEF], [0xC0, 0xFF, 0xEE], Guid.CreateVersion7(), 1, 1);
var json = JsonSerializer.Serialize(payload, Options);
json.ShouldContain(Convert.ToBase64String([0xDE, 0xAD, 0xBE, 0xEF]));
// The data key wrap is a second envelope and must travel the same way. A string here
// instead would mean a client silently storing an item nobody can ever open.
json.ShouldContain(Convert.ToBase64String([0xC0, 0xFF, 0xEE]));
}
[Fact]
public void Enums_AreSerialisedAsStrings()
{
// Integers across the wire would make a reordered enum silently reinterpret data, and
// would make captured payloads unreadable without the matching build.
var change = new SyncChange(
SyncEntityType.Host,
Guid.CreateVersion7(),
SyncOperation.Upsert,
Version: 1,
ChangeSequence: 42,
Payload: null,
PlaintextFields: null,
UpdatedAt: DateTimeOffset.UnixEpoch);
var json = JsonSerializer.Serialize(change, Options);
json.ShouldContain("\"Host\"");
json.ShouldContain("\"Upsert\"");
json.ShouldNotContain("\"entityType\":1");
}
[Fact]
public void EncryptedPayload_RoundTrips()
{
var original = new EncryptedPayload(
[9, 8, 7, 6, 5], [1, 2, 3, 4], Guid.CreateVersion7(), 12, 1);
var restored = JsonSerializer.Deserialize<EncryptedPayload>(
JsonSerializer.Serialize(original, Options), Options);
restored.ShouldNotBeNull();
restored.Envelope.ShouldBe(original.Envelope);
restored.WrappedDataKey.ShouldBe(original.WrappedDataKey);
restored.DataKeyId.ShouldBe(original.DataKeyId);
restored.KeyGeneration.ShouldBe(original.KeyGeneration);
restored.AadVersion.ShouldBe(original.AadVersion);
}
[Fact]
public void SyncPushRequest_RoundTripsWithAllFields()
{
var original = new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(),
SyncEntityType.Host,
Guid.CreateVersion7(),
SyncOperation.Upsert,
ExpectedVersion: 3,
Payload: new EncryptedPayload([1, 2, 3], [4, 5], Guid.CreateVersion7(), 2, 1),
PlaintextFields: new SyncPlaintextFields(
RelayEnabled: true,
Hostname: "bastion.internal",
Port: 22)),
new SyncPushOperation(
Guid.CreateVersion7(),
SyncEntityType.Credential,
Guid.CreateVersion7(),
SyncOperation.Delete,
ExpectedVersion: 7,
Payload: null,
PlaintextFields: null),
]);
var restored = JsonSerializer.Deserialize<SyncPushRequest>(
JsonSerializer.Serialize(original, Options), Options);
restored.ShouldNotBeNull();
restored.Operations.Count.ShouldBe(2);
restored.Operations[0].PlaintextFields!.Hostname.ShouldBe("bastion.internal");
restored.Operations[0].PlaintextFields!.RelayEnabled.ShouldBeTrue();
restored.Operations[1].Payload.ShouldBeNull();
restored.Operations[1].Operation.ShouldBe(SyncOperation.Delete);
}
[Fact]
public void NullFields_AreOmitted()
{
var request = new SyncPullRequest(Cursor: null, Limit: null, EntityTypes: null);
var json = JsonSerializer.Serialize(request, Options);
json.ShouldNotContain("cursor");
json.ShouldNotContain("limit");
}
[Fact]
public void StrictRequestOptions_RejectUnmappedMembers()
{
// A renamed or misspelled client property must surface as a 400, not as a silently
// missing value that later looks like data loss.
const string Json = """
{"cursor":null,"limit":50,"entityTypes":null,"unexpectedProperty":"surprise"}
""";
Should.Throw<JsonException>(() =>
JsonSerializer.Deserialize<SyncPullRequest>(Json, DodoSshJsonContext.StrictRequestOptions));
}
[Fact]
public void StrictRequestOptions_AcceptAWellFormedRequest()
{
const string Json = """
{"cursor":"abc","limit":50}
""";
var request = JsonSerializer.Deserialize<SyncPullRequest>(
Json, DodoSshJsonContext.StrictRequestOptions);
request.ShouldNotBeNull();
request.Cursor.ShouldBe("abc");
request.Limit.ShouldBe(50);
}
[Fact]
public void ResponseOptions_TolerateUnknownMembers()
{
// Forward compatibility: an older client must still read a newer server's response
// rather than failing on a field it does not know about.
const string Json = """
{"envelope":"AQID","keyGeneration":1,"aadVersion":1,"futureField":true}
""";
var payload = JsonSerializer.Deserialize<EncryptedPayload>(Json, Options);
payload.ShouldNotBeNull();
payload.KeyGeneration.ShouldBe(1u);
}
[Fact]
public void NumberHandling_IsStrict()
{
// A quoted number would let a sloppy client send "1" where 1 is meant, which then
// diverges between implementations.
const string Json = """
{"envelope":"AQID","keyGeneration":"1","aadVersion":1}
""";
Should.Throw<JsonException>(() => JsonSerializer.Deserialize<EncryptedPayload>(Json, Options));
}
[Fact]
public void MetaResponse_RoundTrips()
{
var original = new MetaResponse(
ServerVersion: "0.1.0",
ApiVersions: [1],
SyncProtocolVersion: 1,
CryptoSpecVersion: 1,
Features: ["relay", "teams"],
MinClientVersion: "0.1.0",
MaxOperationsPerPush: 500,
MaxPayloadBytes: 8 * 1024 * 1024,
MaxItemPayloadBytes: 256 * 1024);
var restored = JsonSerializer.Deserialize<MetaResponse>(
JsonSerializer.Serialize(original, Options), Options);
restored.ShouldNotBeNull();
restored.Features.ShouldBe(["relay", "teams"]);
restored.MaxPayloadBytes.ShouldBe(8 * 1024 * 1024);
}
[Fact]
public void RelayTicketRequest_HasNoAddressField()
{
// Structural guard on ADR 0004: if a client could name its own target, the relay
// would become an authenticated open TCP proxy into the operator's network.
var properties = typeof(RelayTicketRequest)
.GetProperties()
.Select(p => p.Name)
.ToList();
properties.ShouldBe(["HostId", "PortForwardId"], ignoreOrder: true);
}
}