feat: freeze v1 transport contracts (#4)
quality-gate / quality (push) Successful in 51s

Closes #4
This commit is contained in:
KyuubiYoru
2026-07-16 04:52:38 +02:00
parent e626b89909
commit 69c8b2d2bc
43 changed files with 3737 additions and 12 deletions
@@ -0,0 +1,140 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class ContractLimitTests
{
[Fact]
public void ByteAndCollectionLimitsAcceptTheBoundaryOnly()
{
Assert.True(ContractValidation.IsHttpRequestSizeValid(ContractLimits.HttpRequestMaxBytes));
Assert.False(ContractValidation.IsHttpRequestSizeValid(ContractLimits.HttpRequestMaxBytes + 1));
Assert.True(ContractValidation.IsBrowserResponseSizeValid(ContractLimits.BrowserResponseMaxBytes));
Assert.False(ContractValidation.IsBrowserResponseSizeValid(ContractLimits.BrowserResponseMaxBytes + 1));
Assert.True(ContractValidation.IsPageSizeValid(ContractLimits.BrowserPageMaxItems));
Assert.False(ContractValidation.IsPageSizeValid(ContractLimits.BrowserPageMaxItems + 1));
Assert.False(ContractValidation.IsPageSizeValid(0));
}
[Fact]
public void Utf8LimitsCountBytesInsteadOfCharacters()
{
string atLimit = new('é', ContractLimits.DisplayNameMaxBytes / 2);
string overLimit = atLimit + "é";
Assert.True(ContractValidation.IsDisplayNameValid(atLimit));
Assert.False(ContractValidation.IsDisplayNameValid(overLimit));
Assert.True(ContractValidation.IsBuildVersionValid(
new string('a', ContractLimits.BuildVersionMaxBytes)));
Assert.False(ContractValidation.IsBuildVersionValid(
new string('a', ContractLimits.BuildVersionMaxBytes + 1)));
}
[Fact]
public void CapabilityLimitStaysBelowLiteNetLibTokenLimit()
{
Assert.True(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters - 1)));
Assert.True(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters)));
Assert.False(ContractValidation.IsCapabilityValid(
new string('a', ContractLimits.UdpCapabilityMaxCharacters + 1)));
Assert.False(ContractValidation.IsCapabilityValid("not+base64url"));
Assert.True(
ContractLimits.UdpCapabilityMaxCharacters
< ContractLimits.LiteNetLibNatTokenMaxCharacters);
}
[Fact]
public void CapacityAndMetadataLimitsAreBounded()
{
Assert.True(ContractValidation.IsCapacityValid(new SessionCapacity
{
CurrentPlayers = ContractLimits.SessionCapacityMaxPlayers,
MaximumPlayers = ContractLimits.SessionCapacityMaxPlayers,
}));
Assert.False(ContractValidation.IsCapacityValid(new SessionCapacity
{
CurrentPlayers = 0,
MaximumPlayers = ContractLimits.SessionCapacityMaxPlayers + 1,
}));
Dictionary<string, string> maximumKeys = Enumerable
.Range(0, ContractLimits.MetadataMaxKeys)
.ToDictionary(index => $"key-{index}", _ => "value", StringComparer.Ordinal);
Dictionary<string, string> tooManyKeys = new(maximumKeys, StringComparer.Ordinal)
{
["overflow"] = "value",
};
Assert.True(ContractValidation.IsMetadataValid(maximumKeys));
Assert.False(ContractValidation.IsMetadataValid(tooManyKeys));
Assert.False(ContractValidation.IsMetadataValid(new Dictionary<string, string>
{
[new string('k', ContractLimits.MetadataKeyMaxBytes + 1)] = "value",
}));
Assert.False(ContractValidation.IsMetadataValid(new Dictionary<string, string>
{
["key"] = new string('v', ContractLimits.MetadataValueMaxBytes + 1),
}));
}
[Fact]
public void MetadataDocumentLimitAcceptsExactlyFourKibibytes()
{
Dictionary<string, string> atLimit = Enumerable
.Range(0, ContractLimits.MetadataMaxKeys)
.ToDictionary(index => $"k{index:00}", _ => string.Empty, StringComparer.Ordinal);
for (int index = 0; index < 14; index++)
{
atLimit[$"k{index:00}"] = new string('v', ContractLimits.MetadataValueMaxBytes);
}
atLimit["k14"] = new string('v', 223);
Dictionary<string, string> overLimit = new(atLimit, StringComparer.Ordinal)
{
["k14"] = new string('v', 224),
};
Assert.True(ContractValidation.IsMetadataValid(atLimit));
Assert.False(ContractValidation.IsMetadataValid(overLimit));
}
[Fact]
public void EndpointValidationIsAddressFamilyAware()
{
Assert.True(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "192.0.2.10",
Port = 9050,
}));
Assert.True(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv6,
Address = "2001:db8::10",
Port = 9050,
}));
Assert.False(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "2001:db8::10",
Port = 9050,
}));
}
[Fact]
public void ScopeAndOpaqueTextBoundariesAreEnforced()
{
Assert.True(GameId.TryParse(new string('a', ContractLimits.GameIdMaxCharacters), out _));
Assert.False(GameId.TryParse(
new string('a', ContractLimits.GameIdMaxCharacters + 1),
out _));
Assert.True(ContractValidation.IsIdempotencyKeyValid(
new string('i', ContractLimits.IdempotencyKeyMaxCharacters)));
Assert.False(ContractValidation.IsIdempotencyKeyValid(
new string('i', ContractLimits.IdempotencyKeyMaxCharacters + 1)));
Assert.True(ContractValidation.IsCursorValid(null));
Assert.False(ContractValidation.IsCursorValid("contains whitespace"));
}
}
@@ -0,0 +1,104 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class ContractSerializationTests
{
public static TheoryData<string, Type> GoldenJsonVectors => new()
{
{ "register-session.json", typeof(RegisterSessionRequest) },
{ "browse-sessions.json", typeof(BrowseSessionsResponse) },
{ "create-join-response.json", typeof(CreateJoinAttemptResponse) },
{ "api-error.json", typeof(ApiError) },
};
[Theory]
[MemberData(nameof(GoldenJsonVectors))]
public void CanonicalJsonRoundtripsGoldenVectors(string fileName, Type contractType)
{
string expected = ContractTestFiles.Read(fileName);
object? value = JsonSerializer.Deserialize(expected, contractType, ContractJson.Options);
Assert.NotNull(value);
Assert.Equal(expected, JsonSerializer.Serialize(value, contractType, ContractJson.Options));
}
[Fact]
public void IdentifiersAreSerializedAsStrings()
{
SessionListingId id = new(new Guid("00112233-4455-6677-8899-aabbccddeeff"));
Assert.Equal(
"\"00112233-4455-6677-8899-aabbccddeeff\"",
JsonSerializer.Serialize(id, ContractJson.Options));
Assert.Equal(id, JsonSerializer.Deserialize<SessionListingId>(
"\"00112233-4455-6677-8899-aabbccddeeff\"",
ContractJson.Options));
}
[Fact]
public void UnknownObjectFieldsAreIgnoredForAdditiveV1Changes()
{
const string json = """
{"contractVersion":1,"idempotencyKey":"join-001","gameId":"space-game","environmentId":"production","listingId":"00112233-4455-6677-8899-aabbccddeeff","protocolVersion":7,"futureField":{"nested":true}}
""";
CreateJoinAttemptRequest? request = JsonSerializer.Deserialize<CreateJoinAttemptRequest>(
json,
ContractJson.Options);
Assert.NotNull(request);
Assert.Equal(new GameId("space-game"), request.GameId);
Assert.Equal(7u, request.ProtocolVersion);
}
[Fact]
public void MissingNormativeFieldsAreRejectedInsteadOfDefaulted()
{
const string missingVersion = """
{"idempotencyKey":"join-001","gameId":"space-game","environmentId":"production","listingId":"00112233-4455-6677-8899-aabbccddeeff","protocolVersion":7}
""";
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<CreateJoinAttemptRequest>(
missingVersion,
ContractJson.Options));
}
[Fact]
public void UnknownEnumNamesAndNumericValuesAreRejected()
{
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}",
ContractJson.Options));
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}",
ContractJson.Options));
}
[Theory]
[InlineData(0)]
[InlineData(2)]
[InlineData(int.MaxValue)]
public void UnknownContractVersionsFailPredictably(int version)
{
Assert.Equal(
RendezvousErrorCode.UnsupportedContractVersion,
ContractValidation.ValidateContractVersion(version));
}
[Fact]
public void GameplayProtocolCompatibilityIsExactAndBuildIndependent()
{
Assert.True(ContractValidation.AreProtocolsCompatible(7, 7));
Assert.False(ContractValidation.AreProtocolsCompatible(7, 8));
}
[Fact]
public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
{
Assert.True(ContractJson.Options.IsReadOnly);
Assert.Throws<InvalidOperationException>(() =>
ContractJson.Options.WriteIndented = true);
}
}
@@ -0,0 +1,30 @@
namespace FinalFactory.Rendezvous.Tests.Contracts;
internal static class ContractTestFiles
{
public static string Read(string fileName) => File
.ReadAllText(Path.Combine(Directory, fileName))
.TrimEnd('\r', '\n');
public static string Directory
{
get
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string solution = Path.Combine(directory.FullName, "Rendezvous.slnx");
if (File.Exists(solution))
{
return Path.Combine(
directory.FullName,
"tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1");
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate contract test data.");
}
}
}
@@ -0,0 +1,68 @@
using System.Text.Json;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class OpenApiCompatibilityTests
{
private static readonly string[] ExpectedPaths =
[
"/health/live",
"/health/ready",
"/v1/join-attempts",
"/v1/join-attempts/{attemptId}/outcome",
"/v1/sessions",
"/v1/sessions/{listingId}",
"/v1/sessions/{listingId}/join-attempts",
"/v1/sessions/{listingId}/renew",
];
private static readonly string[] ExpectedListingProperties =
[
"buildVersion",
"capacity",
"contractVersion",
"displayName",
"environmentId",
"gameId",
"listingId",
"metadata",
"protocolVersion",
"publisherTrustMode",
"regionId",
"visibility",
];
[Fact]
public void GeneratedOpenApiContainsTheFrozenV1Surface()
{
string path = Path.Combine(
ContractTestFiles.Directory,
"../../../../../docs/api/rendezvous-v1.json");
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(Path.GetFullPath(path)));
JsonElement root = document.RootElement;
Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
string[] paths = root.GetProperty("paths")
.EnumerateObject()
.Select(static item => item.Name)
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(ExpectedPaths, paths);
JsonElement schemas = root.GetProperty("components").GetProperty("schemas");
Assert.Equal("string", schemas.GetProperty("GameId").GetProperty("type").GetString());
Assert.Equal("uuid", schemas.GetProperty("SessionListingId").GetProperty("format").GetString());
string[] listingProperties = schemas.GetProperty("SessionListing")
.GetProperty("properties")
.EnumerateObject()
.Select(static item => item.Name)
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(ExpectedListingProperties, listingProperties);
Assert.DoesNotContain(listingProperties, static property =>
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,103 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class PublicApiCompatibilityTests
{
[Fact]
public void ContractsPublicApiMatchesTheV1Snapshot()
{
string snapshot = CreateSnapshot(typeof(ContractLimits).Assembly);
string expected = ContractTestFiles.Read("contracts-public-api.txt");
if (expected == "SNAPSHOT_PENDING"
&& Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1")
{
string snapshotPath = Path.Combine(
ContractTestFiles.Directory,
"contracts-public-api.txt");
File.WriteAllText(snapshotPath, snapshot + Environment.NewLine);
expected = snapshot;
}
Assert.Equal(expected, snapshot);
}
private static string CreateSnapshot(Assembly assembly)
{
List<string> lines = [];
foreach (Type type in assembly.GetExportedTypes().OrderBy(static type => type.FullName, StringComparer.Ordinal))
{
lines.Add($"TYPE {FormatType(type)}");
if (type.IsEnum)
{
foreach (string name in Enum.GetNames(type))
{
object value = Enum.Parse(type, name);
lines.Add($" ENUM {name}={Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture)}");
}
continue;
}
foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(static field => field.Name, StringComparer.Ordinal))
{
string value = field.IsLiteral
? Convert.ToString(field.GetRawConstantValue(), System.Globalization.CultureInfo.InvariantCulture) ?? "null"
: "non-literal";
lines.Add($" FIELD {FormatType(field.FieldType)} {field.Name}={value}");
}
foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.Public | BindingFlags.Instance)
.OrderBy(static constructor => FormatParameters(constructor.GetParameters()), StringComparer.Ordinal))
{
lines.Add($" CTOR ({FormatParameters(constructor.GetParameters())})");
}
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(static property => property.Name, StringComparer.Ordinal))
{
string accessors = $"{(property.CanRead ? "get;" : string.Empty)}{(property.CanWrite ? "set;" : string.Empty)}";
lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
}
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))
.OrderBy(static method => method.Name, StringComparer.Ordinal)
.ThenBy(static method => FormatParameters(method.GetParameters()), StringComparer.Ordinal))
{
lines.Add($" METHOD {FormatType(method.ReturnType)} {method.Name}({FormatParameters(method.GetParameters())})");
}
}
return string.Join('\n', lines);
}
private static string FormatParameters(ParameterInfo[] parameters) => string.Join(
", ",
parameters.Select(static parameter =>
$"{FormatType(parameter.ParameterType)} {parameter.Name}"));
private static string FormatType(Type type)
{
if (type.IsByRef)
{
return $"{FormatType(type.GetElementType()!)}&";
}
if (type.IsArray)
{
return $"{FormatType(type.GetElementType()!)}[]";
}
if (type.IsGenericType)
{
string name = type.GetGenericTypeDefinition().FullName!;
name = name[..name.IndexOf('`')];
return $"{name}<{string.Join(",", type.GetGenericArguments().Select(FormatType))}>";
}
return type.FullName ?? type.Name;
}
}
@@ -0,0 +1,101 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class UdpCodecTests
{
private static readonly Guid Handle = new("00112233-4455-6677-8899-aabbccddeeff");
[Fact]
public void HostPresenceMatchesTheV1GoldenVector()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
Assert.Equal(ContractTestFiles.Read("udp-host-ipv4.hex"), Convert.ToHexString(encoded).ToLowerInvariant());
Assert.True(RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? decoded, out UdpDecodeError error));
Assert.Equal(UdpDecodeError.None, error);
Assert.NotNull(decoded);
Assert.Equal(Handle, decoded.MediationHandle.Value);
Assert.Equal("192.0.2.10", decoded.LocalAddress);
Assert.Equal(9050, decoded.LocalPort);
Assert.Equal("Abc_123-xYz", decoded.Capability);
}
[Fact]
public void Ipv6RoundtripsWithoutLosingItsAddressFamily()
{
PresenceDatagram original = CreateDatagram();
original.MessageType = UdpPresenceMessageType.ClientPresence;
original.AddressFamily = AddressFamilyKind.Ipv6;
original.LocalAddress = "2001:db8::10";
byte[] encoded = RendezvousUdpCodec.Encode(original);
Assert.True(RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? decoded, out _));
Assert.NotNull(decoded);
Assert.Equal(AddressFamilyKind.Ipv6, decoded.AddressFamily);
Assert.Equal("2001:db8::10", decoded.LocalAddress);
}
[Fact]
public void EncoderRejectsAnAddressFamilyMismatch()
{
PresenceDatagram datagram = CreateDatagram();
datagram.AddressFamily = AddressFamilyKind.Ipv4;
datagram.LocalAddress = "2001:db8::10";
Assert.Throws<ArgumentException>(() => RendezvousUdpCodec.Encode(datagram));
}
[Theory]
[InlineData(2, 2, UdpDecodeError.UnsupportedVersion)]
[InlineData(3, 3, UdpDecodeError.UnknownMessageType)]
[InlineData(4, 1, UdpDecodeError.InvalidFlags)]
[InlineData(21, 5, UdpDecodeError.InvalidAddressFamily)]
public void DecoderReturnsStableErrorsForUnknownHeaderValues(
int offset,
byte replacement,
UdpDecodeError expected)
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
encoded[offset] = replacement;
Assert.False(RendezvousUdpCodec.TryDecode(encoded, out _, out UdpDecodeError actual));
Assert.Equal(expected, actual);
}
[Fact]
public void DecoderRejectsTruncationTrailingDataAndOversizedPackets()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
byte[] trailing = [.. encoded, 0];
byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
Assert.False(RendezvousUdpCodec.TryDecode(encoded.AsSpan(0, encoded.Length - 1), out _, out UdpDecodeError truncated));
Assert.Equal(UdpDecodeError.Truncated, truncated);
Assert.False(RendezvousUdpCodec.TryDecode(trailing, out _, out UdpDecodeError trailingError));
Assert.Equal(UdpDecodeError.TrailingData, trailingError);
Assert.False(RendezvousUdpCodec.TryDecode(oversized, out _, out UdpDecodeError oversizedError));
Assert.Equal(UdpDecodeError.DatagramTooLarge, oversizedError);
}
[Fact]
public void DecoderRejectsNonBase64UrlCapabilities()
{
byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
encoded[^1] = (byte)'+';
Assert.False(RendezvousUdpCodec.TryDecode(encoded, out _, out UdpDecodeError error));
Assert.Equal(UdpDecodeError.InvalidCapability, error);
}
private static PresenceDatagram CreateDatagram() => new()
{
MessageType = UdpPresenceMessageType.HostPresence,
MediationHandle = new MediationHandle(Handle),
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.0.2.10",
LocalPort = 9050,
Capability = "Abc_123-xYz",
};
}