using System.Text.Json; using DodoSSH.Contracts; namespace DodoSSH.Contracts.Tests; /// /// Wire-format behaviour of the shared contracts. /// /// /// 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. /// 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( 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( 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(() => JsonSerializer.Deserialize(Json, DodoSshJsonContext.StrictRequestOptions)); } [Fact] public void StrictRequestOptions_AcceptAWellFormedRequest() { const string Json = """ {"cursor":"abc","limit":50} """; var request = JsonSerializer.Deserialize( 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(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(() => JsonSerializer.Deserialize(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( 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); } }