Closes #4
This commit is contained in:
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user