Files
DodoSSH/tests/DodoSSH.Domain.Tests/Sync/SyncCursorTests.cs
T
jaap-jan 3829217e8a Add sync engine: cursors, push/pull, and the advisory-lock ordering proof (M1)
The vault write path. Push is the only way items change — no per-entity POST/PUT/DELETE —
so one place enforces revisions, the change log and access control.

The concurrency hazard, now proven rather than asserted:
bigserial assigns sequence values when the INSERT runs, not at commit, so transaction A
can take sequence 5 while B takes 6 and commits first. A reader polling in between sees
only 6, advances past 5, and never learns about it. AdvisoryLockOrderingTests reproduces
that gap WITHOUT the lock first — otherwise the with-lock test proves nothing, since it
would pass just as happily if the interleaving never occurred — then shows
pg_advisory_xact_lock removes it, and that 12 concurrent writers produce no gaps.

Cursors are opaque and HMAC-tagged, and carry their vault id. 29 unit tests cover the
rejections, which are the point: an accepted-but-wrong cursor is silent data loss, strictly
worse than an error a client can resync from. Rejected: tampered tag, tampered payload,
foreign signing key, a legitimately-issued cursor from another vault, truncation, and
hostile input (never throws — cursors come from clients).

Push semantics:
- 200 even on partial failure, with per-operation status, so one stale item cannot block
  everything a client queued while offline.
- Conflict returns the server's current row for client-side three-way merge. The server
  cannot merge ciphertext, so never last-writer-wins.
- opId receipts make retries exactly-once per operation, not per batch — a client retrying
  a partially-overlapping batch after a timeout would otherwise double-apply what landed.
- A tombstone beats a late upsert, and delete clears hostname/port: leaving the address
  would keep the server able to resolve a host the user believes they deleted.
- Relay field validation mirrors the DB CHECK so a bad request is a clear Invalid rather
  than a constraint violation surfacing as a 500.

Authorization goes through IVaultAccessService, which returns the same answer for "absent"
and "forbidden" — distinguishing them is an existence oracle for other tenants' vault ids.
Team vaults are explicitly denied until M3 rather than falling through to a permissive
default. JIT provisioning keys on (issuer, subject), never email, and handles the
concurrent-first-request race via the unique index.

Renamed two domain types: Host -> SshHost, because Host collides with
Microsoft.Extensions.Hosting.Host in every file of a web project, and SyncChange ->
VaultChange to stop it colliding with the Contracts DTO of the same name. Aliasing at every
use site would have been permanent friction.

Worth noting: `ef migrations has-pending-model-changes` reported clean after those renames
even though the snapshot still said "DodoSSH.Domain.Host" — it diffs tables, not CLR type
names. The snapshot was regenerated and the emitted DDL diffed against the previous
artifacts/schema/v0.1.sql to confirm the rename produced no schema change.

Also removed ConfigureAwait(false) from test methods: xUnit1030 flags it as bypassing
parallelization limits, which is why MA0004 is suppressed in test projects.

Verified: 0 warnings on a clean rebuild, 146 tests pass (up from 122), format clean.

Endpoint-level tests are the immediate next step: they need a WireMock OIDC/JWKS stub and
real JWT minting, so the "wrong user is denied" matrix does not exist yet for these two
routes. The service-layer authorization and the concurrency property are covered.
2026-07-28 15:02:02 +02:00

156 lines
5.0 KiB
C#

using System.Security.Cryptography;
using DodoSSH.Domain.Sync;
namespace DodoSSH.Domain.Tests.Sync;
/// <summary>
/// Cursor encoding and, more importantly, every way a bad cursor must be rejected.
/// </summary>
/// <remarks>
/// The negative cases are the point. An accepted-but-wrong cursor causes silent data loss — the
/// client believes it is up to date while having skipped changes — which is strictly worse than an
/// error the client can retry from scratch.
/// </remarks>
public sealed class SyncCursorTests
{
private static readonly byte[] Key = RandomNumberGenerator.GetBytes(32);
private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid OtherVaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
[Theory]
[InlineData(0L)]
[InlineData(1L)]
[InlineData(42L)]
[InlineData(long.MaxValue)]
public void RoundTrips(long sequence)
{
var cursor = SyncCursor.Encode(Key, VaultId, sequence);
SyncCursor.TryDecode(Key, cursor, VaultId, out var decoded).ShouldBeTrue();
decoded.ShouldBe(sequence);
}
[Fact]
public void IsDeterministic()
{
SyncCursor.Encode(Key, VaultId, 7).ShouldBe(SyncCursor.Encode(Key, VaultId, 7));
}
[Fact]
public void IsUrlSafeAndUnpadded()
{
var cursor = SyncCursor.Encode(Key, VaultId, 12345);
cursor.ShouldNotContain("+");
cursor.ShouldNotContain("/");
cursor.ShouldNotContain("=");
}
[Fact]
public void DoesNotRevealTheSequenceInPlainSight()
{
// Not a security property — the position is not secret — but it discourages clients from
// parsing or synthesising cursors, which is what the opacity is actually for.
SyncCursor.Encode(Key, VaultId, 987654).ShouldNotContain("987654");
}
[Fact]
public void RejectsATamperedTag()
{
var cursor = SyncCursor.Encode(Key, VaultId, 100);
var tampered = cursor[..^1] + (cursor[^1] == 'A' ? 'B' : 'A');
SyncCursor.TryDecode(Key, tampered, VaultId, out _).ShouldBeFalse();
}
[Fact]
public void RejectsATamperedPayload()
{
// The attack this prevents: rewriting the sequence to skip ahead, so the client never
// learns about the changes in between.
var cursor = SyncCursor.Encode(Key, VaultId, 100);
var mutated = cursor.ToCharArray();
mutated[0] = mutated[0] == 'x' ? 'y' : 'x';
SyncCursor.TryDecode(Key, new string(mutated), VaultId, out _).ShouldBeFalse();
}
[Fact]
public void RejectsACursorSignedWithAnotherKey()
{
var foreign = SyncCursor.Encode(RandomNumberGenerator.GetBytes(32), VaultId, 100);
SyncCursor.TryDecode(Key, foreign, VaultId, out _).ShouldBeFalse();
}
[Fact]
public void RejectsACursorIssuedForAnotherVault()
{
// Legitimately issued and correctly tagged, but for a different vault. Without the vault
// id inside the payload this would decode to a sequence from an unrelated log and serve
// the wrong slice of history.
var cursor = SyncCursor.Encode(Key, OtherVaultId, 100);
SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-base64!!")]
[InlineData("AAAA")]
[InlineData("A")]
public void RejectsMalformedInput(string? cursor)
{
SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
}
[Fact]
public void RejectsATruncatedCursor()
{
var cursor = SyncCursor.Encode(Key, VaultId, 100);
SyncCursor.TryDecode(Key, cursor[..(cursor.Length / 2)], VaultId, out _).ShouldBeFalse();
}
[Fact]
public void NeverThrowsOnClientSuppliedInput()
{
// Cursors come from clients, so rejection must be a return value rather than an exception
// that becomes a 500.
string[] hostile =
[
"\0", "…", new string('A', 10_000), "____", "----", "v1|x|y", "%%%",
];
foreach (var value in hostile)
{
Should.NotThrow(() => SyncCursor.TryDecode(Key, value, VaultId, out _));
}
}
[Fact]
public void RejectsAnUndersizedSigningKey()
{
// Misconfiguration must fail loudly at the call site rather than producing weak tags.
Should.Throw<ArgumentException>(() => SyncCursor.Encode(new byte[16], VaultId, 1));
Should.Throw<ArgumentException>(() =>
SyncCursor.TryDecode(new byte[31], "whatever", VaultId, out _));
}
[Fact]
public void RejectsANegativeSequence()
{
Should.Throw<ArgumentOutOfRangeException>(() => SyncCursor.Encode(Key, VaultId, -1));
}
[Fact]
public void DifferentSequencesProduceDifferentCursors()
{
var first = SyncCursor.Encode(Key, VaultId, 1);
var second = SyncCursor.Encode(Key, VaultId, 2);
string.Equals(first, second, StringComparison.Ordinal).ShouldBeFalse();
}
}