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

189 lines
6.6 KiB
C#

using System.Text;
using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// The payload encoding.
/// </summary>
/// <remarks>
/// Two properties carry weight here. Determinism, because the sync engine compares to decide whether
/// to push, and a codec that produced different bytes for the same host would make every pass look
/// like a change. And failing closed on anything malformed, because these bytes are decrypted inside
/// a sync pass where an exception would strand every item queued behind the bad one.
/// </remarks>
public sealed class HostSecretCodecTests
{
[Fact]
public void AFullHost_RoundTrips()
{
var host = Host(
label: "prod-db",
hostname: "db.internal",
port: 2222,
username: "deploy",
notes: "primary replica",
jumps: [Bastion, Relay],
options: [("ServerAliveInterval", "30"), ("Compression", "yes")]);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Host.ShouldBe(host);
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void AMinimalHost_RoundTrips()
{
var host = Host(username: null, notes: null);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
document!.Host.ShouldBe(host);
document.Host.Username.ShouldBeNull();
document.Host.Notes.ShouldBeNull();
}
[Fact]
public void Encoding_IsDeterministic()
{
var host = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
HostSecretCodec.Encode(host).ShouldBe(HostSecretCodec.Encode(host));
}
[Fact]
public void DirectiveOrder_DoesNotAffectTheEncoding()
{
// Two clients that agree on the content must produce the same bytes regardless of the order
// the user happened to type the directives in.
var one = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
var other = Host(options: [("ServerAliveInterval", "30"), ("Compression", "yes")]);
HostSecretCodec.Encode(one).ShouldBe(HostSecretCodec.Encode(other));
}
[Fact]
public void APayloadFromANewerSchema_IsReadableButReadOnly()
{
// The forward-compatibility rule. An old client can show the host but must not re-encode it,
// because it has no representation for the newer client's extra fields and would drop them.
var payload = Json("""
{
"schemaVersion": 99,
"label": "prod-db",
"hostname": "db.internal",
"port": 22,
"unknownFutureField": { "nested": true }
}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document!.Host.Label.ShouldBe("prod-db");
document.Host.Hostname.ShouldBe("db.internal");
document.IsReadOnly.ShouldBeTrue();
}
[Fact]
public void AnUnknownFieldAtTheCurrentSchema_IsSkippedRatherThanFatal()
{
var payload = Json("""
{
"schemaVersion": 1,
"label": "prod-db",
"hostname": "db.internal",
"port": 22,
"somethingElse": 5
}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document!.IsReadOnly.ShouldBeFalse();
}
[Theory]
[InlineData("")]
[InlineData("not json at all")]
[InlineData("{")]
[InlineData("[]")]
[InlineData("null")]
public void MalformedBytes_ReturnFalseRatherThanThrow(string text)
{
HostSecretCodec.TryDecode(Json(text), out var document).ShouldBeFalse();
document.ShouldBeNull();
}
[Theory]
[InlineData("""{ "schemaVersion": 0, "label": "a", "hostname": "b", "port": 22 }""")]
[InlineData("""{ "schemaVersion": -1, "label": "a", "hostname": "b", "port": 22 }""")]
[InlineData("""{ "schemaVersion": 1, "label": "", "hostname": "b", "port": 22 }""")]
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "", "port": 22 }""")]
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 0 }""")]
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 70000 }""")]
public void AStructurallyInvalidPayload_IsRejected(string json)
{
HostSecretCodec.TryDecode(Json(json), out _).ShouldBeFalse();
}
[Fact]
public void DuplicateDirectiveNamesDifferingOnlyInCase_AreRejected()
{
// Fails closed. SSH treats keywords case-insensitively, so this payload has no single
// meaning; guessing which one wins would make two clients disagree about the same bytes.
var payload = Json("""
{
"schemaVersion": 1,
"label": "prod-db",
"hostname": "db.internal",
"port": 22,
"options": { "Compression": "yes", "compression": "no" }
}
""");
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
}
[Fact]
public void AnEmptyJumpHostId_IsRejected()
{
var payload = Json($$"""
{
"schemaVersion": 1,
"label": "prod-db",
"hostname": "db.internal",
"port": 22,
"jumpHostIds": ["{{Guid.Empty}}"]
}
""");
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
}
[Fact]
public void Encode_RefusesAnInvalidHost()
{
// Throwing rather than returning false, because unlike decoding, this is a caller bug: the
// host came from this process and should have been validated before it got here.
Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(label: " ")));
Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(port: 0)));
}
[Fact]
public void TheEncoding_CarriesNoPlaintextOutsideTheEnvelope()
{
// A reminder of what this codec is for: every one of these values is inside the ciphertext.
// There is no plaintext host label anywhere in the system.
var host = Host(label: "prod-db", notes: "root password in 1Password");
var text = Encoding.UTF8.GetString(HostSecretCodec.Encode(host));
text.Contains("prod-db", StringComparison.Ordinal).ShouldBeTrue();
text.Contains("1Password", StringComparison.Ordinal).ShouldBeTrue();
}
private static byte[] Json(string text) => Encoding.UTF8.GetBytes(text);
}