Public Access
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.
216 lines
7.3 KiB
C#
216 lines
7.3 KiB
C#
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);
|
|
}
|
|
}
|