Freeze DodoSSH.Contracts v0.1 (M1)

The second M1 gate. This assembly, not the OpenAPI document, is the client's contract,
so PublicApiAnalyzers now tracks all 540 public members: a renamed DTO property becomes
a build error rather than a runtime deserialisation failure on someone's laptop.

Contract surface:
- EncryptedPayload carries the envelope plus the KeyGeneration and AadVersion columns
  needed to recompute AAD, since AAD is derived from the row rather than transmitted.
- Sync: push with per-operation status (Applied/Conflict/Forbidden/Invalid/Duplicate) so
  one stale item cannot block a whole offline queue; a Conflict returns the server's row
  for client-side three-way merge, because the server cannot merge ciphertext.
- Enrollment: KeyStatement whose hash becomes the OIDC nonce, so the identity provider
  signs over the public keys and this server cannot fabricate a key for a user who never
  enrolled.
- Meta and .well-known configuration: capability negotiation instead of URL versioning,
  which is what a self-hosted product needs when client and server upgrade independently.
- SyncPlaintextFields deliberately has no label or name field. ACL admin runs client-side
  where names can be decrypted, so the server never needs a searchable title.

Two design problems found by writing the tests rather than assuming:
- Hand-constructing JsonSerializerOptions and merely pointing its resolver at the context
  silently discards every source-generated setting. JsonSerializerDefaults.Web replaces
  NumberHandling.Strict with AllowReadingFromString, so "1" would be accepted where 1 is
  meant — invisible until two implementations disagree. Callers now use ResponseOptions or
  StrictRequestOptions; StrictRequestOptions is derived by copying so it cannot drift.
- StrictRequestOptions had a static-initialisation cycle: it read the generated Default
  property from the same type's initialiser and got null. Now lazy.

Requests reject unmapped members so a client typo is a 400; responses tolerate them so an
older client can read a newer server. Enums cross the wire as strings, so reordering one
cannot silently reinterpret stored data.

Also: excluded source-generator output from PublicApiAnalyzers. The JSON generator emits a
public member per serialisable type, which would have added hundreds of mechanical entries
and drowned the ones describing the actual wire contract. And disabled MA0048's
one-type-per-file rule: splitting SyncPullRequest from SyncPullResponse makes a reviewer
open two files to understand one endpoint.

Verified: 0 warnings, 95 tests pass, format clean.
This commit is contained in:
2026-07-28 13:28:02 +02:00
parent b15af836a3
commit 06d04b490b
12 changed files with 1472 additions and 0 deletions
@@ -0,0 +1,215 @@
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], KeyGeneration: 4, AadVersion: 1);
var json = JsonSerializer.Serialize(payload, Options);
json.ShouldContain("\"envelope\"");
json.ShouldContain("\"keyGeneration\"");
json.ShouldContain("\"aadVersion\"");
}
[Fact]
public void ByteArrays_AreBase64()
{
var payload = new EncryptedPayload([0xDE, 0xAD, 0xBE, 0xEF], 1, 1);
var json = JsonSerializer.Serialize(payload, Options);
json.ShouldContain(Convert.ToBase64String([0xDE, 0xAD, 0xBE, 0xEF]));
}
[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], 12, 1);
var restored = JsonSerializer.Deserialize<EncryptedPayload>(
JsonSerializer.Serialize(original, Options), Options);
restored.ShouldNotBeNull();
restored.Envelope.ShouldBe(original.Envelope);
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], 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);
}
}