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],
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(
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(
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);
}
}