feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s

Closes #10
This commit is contained in:
KyuubiYoru
2026-07-16 06:56:30 +02:00
parent 06c3973ce7
commit 1baa1055dc
30 changed files with 2057 additions and 113 deletions
@@ -0,0 +1,102 @@
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class ConnectionTicketValidatorTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void AuthorizedTicketIsAcceptedExactlyOnce()
{
ManualConnectionTicketClock clock = new();
using ConnectionTicketValidator validator = new(1_024, clock);
JoinAttemptId attempt = NewAttempt();
string ticket = Ticket('A');
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
Assert.Equal(
ConnectionTicketConsumptionResult.Accepted,
validator.Consume(attempt, ticket));
Assert.Equal(
ConnectionTicketConsumptionResult.AlreadyConsumed,
validator.Consume(attempt, ticket));
}
[Fact]
public void AlteredCrossAttemptExpiredAndRevokedTicketsAreRejected()
{
ManualConnectionTicketClock clock = new();
using ConnectionTicketValidator validator = new(1_024, clock);
JoinAttemptId first = NewAttempt();
JoinAttemptId second = NewAttempt();
Assert.True(validator.TryAuthorize(first, Ticket('A'), Now.AddSeconds(20)));
Assert.True(validator.TryAuthorize(second, Ticket('B'), Now.AddSeconds(40)));
Assert.Equal(
ConnectionTicketConsumptionResult.Rejected,
validator.Consume(first, Ticket('B')));
clock.Advance(TimeSpan.FromSeconds(20));
Assert.Equal(
ConnectionTicketConsumptionResult.Expired,
validator.Consume(first, Ticket('A')));
Assert.True(validator.Revoke(second));
Assert.Equal(
ConnectionTicketConsumptionResult.Revoked,
validator.Consume(second, Ticket('B')));
}
[Fact]
public async Task ConcurrentConsumptionHasOneWinner()
{
ManualConnectionTicketClock clock = new();
using ConnectionTicketValidator validator = new(1_024, clock);
JoinAttemptId attempt = NewAttempt();
string ticket = Ticket('C');
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
using ManualResetEventSlim start = new(false);
Task<ConnectionTicketConsumptionResult> left = Task.Run(() =>
{
start.Wait();
return validator.Consume(attempt, ticket);
});
Task<ConnectionTicketConsumptionResult> right = Task.Run(() =>
{
start.Wait();
return validator.Consume(attempt, ticket);
});
start.Set();
ConnectionTicketConsumptionResult[] results = await Task.WhenAll(left, right);
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.Accepted);
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.AlreadyConsumed);
}
[Fact]
public void ValidatorIsBoundedDisposableAndRedacted()
{
ManualConnectionTicketClock clock = new();
ConnectionTicketValidator validator = new(1, clock);
Assert.True(validator.TryAuthorize(NewAttempt(), Ticket('A'), Now.AddSeconds(20)));
Assert.False(validator.TryAuthorize(NewAttempt(), Ticket('B'), Now.AddSeconds(20)));
Assert.DoesNotContain(Ticket('A'), validator.ToString(), StringComparison.Ordinal);
validator.Dispose();
Assert.Throws<ObjectDisposedException>(() => validator.Revoke(NewAttempt()));
Assert.Throws<ObjectDisposedException>(() => validator.Consume(default, string.Empty));
}
private static JoinAttemptId NewAttempt() => new(Guid.NewGuid());
private static string Ticket(char value) => new(value, 43);
private sealed class ManualConnectionTicketClock : IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; set; } = Now;
public void Advance(TimeSpan duration) => UtcNow += duration;
}
}
@@ -9,6 +9,7 @@ public sealed class OpenApiCompatibilityTests
"/health/live",
"/health/ready",
"/v1/join-attempts",
"/v1/join-attempts/{attemptId}",
"/v1/join-attempts/{attemptId}/outcome",
"/v1/sessions",
"/v1/sessions/{listingId}",
@@ -85,5 +86,23 @@ public sealed class OpenApiCompatibilityTests
.GetProperty("security");
Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
}
JsonElement cancelParameters = root.GetProperty("paths")
.GetProperty("/v1/join-attempts/{attemptId}")
.GetProperty("delete")
.GetProperty("parameters");
JsonElement cancelCapability = Assert.Single(cancelParameters.EnumerateArray(), static parameter =>
parameter.GetProperty("in").GetString() == "header"
&& parameter.GetProperty("name").GetString()
== "X-Rendezvous-Client-Punch-Capability");
Assert.True(cancelCapability.GetProperty("required").GetBoolean());
JsonElement hostPollParameters = root.GetProperty("paths")
.GetProperty("/v1/sessions/{listingId}/join-attempts")
.GetProperty("get")
.GetProperty("parameters");
JsonElement leaseToken = Assert.Single(hostPollParameters.EnumerateArray(), static parameter =>
parameter.GetProperty("in").GetString() == "header"
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
Assert.True(leaseToken.GetProperty("required").GetBoolean());
}
}
@@ -0,0 +1,204 @@
using System.Net;
using System.Net.Http.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.State;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
public sealed class JoinAttemptHttpEndpointTests
{
[Fact]
public async Task ClientCreatesHostPollsAndCapabilityCancelsAnAttemptOverHttp()
{
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(),
host.PublisherCredential));
Assert.True(host.Capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint presenceFingerprint));
Assert.True(host.Store.BindHostPresence(new(
session.HostPresenceHandle,
presenceFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
null)).Succeeded);
CreateJoinAttemptRequest request = new()
{
IdempotencyKey = "http-join-1",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = 7,
};
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
request,
ContractJson.Options);
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
using HttpRequestMessage pollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
pollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage pollResponse = await host.HttpClient.SendAsync(pollRequest);
Assert.Equal(HttpStatusCode.OK, pollResponse.StatusCode);
BrowseHostJoinAttemptsResponse polled = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await pollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
HostJoinAttempt hostAttempt = Assert.Single(polled.Items);
Assert.Equal(created.AttemptId, hostAttempt.AttemptId);
Assert.NotEqual(created.ClientPunchCapability, hostAttempt.HostPunchCapability);
using HttpResponseMessage missingCapability = await host.HttpClient.DeleteAsync(
$"v1/join-attempts/{created.AttemptId}");
Assert.Equal(HttpStatusCode.BadRequest, missingCapability.StatusCode);
ApiError missingCapabilityError = Assert.IsType<ApiError>(
await missingCapability.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
Assert.Equal(RendezvousErrorCode.InvalidRequest, missingCapabilityError.Code);
using HttpRequestMessage unauthorizedCancel = new(
HttpMethod.Delete,
$"v1/join-attempts/{created.AttemptId}");
unauthorizedCancel.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedCancel);
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
using HttpRequestMessage cancelRequest = new(
HttpMethod.Delete,
$"v1/join-attempts/{created.AttemptId}");
cancelRequest.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
created.ClientPunchCapability);
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
using HttpRequestMessage emptyPollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
Assert.Empty(empty.Items);
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{
Assert.True(result.IsSuccess, result.Message);
return Assert.IsAssignableFrom<T>(result.Value);
}
private static RegisterSessionRequest CreateRegistration() => new()
{
IdempotencyKey = "join-http-host",
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "Join HTTP host",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
Metadata = new() { ["mode"] = "online-coop" },
};
private sealed class JoinHttpTestHost : IAsyncDisposable
{
private readonly WebApplication _application;
private JoinHttpTestHost(
WebApplication application,
HttpClient httpClient,
InMemoryEphemeralRendezvousStore store,
EphemeralCapabilityIssuer capabilities,
string publisherCredential)
{
_application = application;
HttpClient = httpClient;
Store = store;
Capabilities = capabilities;
PublisherCredential = publisherCredential;
}
internal HttpClient HttpClient { get; }
internal InMemoryEphemeralRendezvousStore Store { get; }
internal EphemeralCapabilityIssuer Capabilities { get; }
internal string PublisherCredential { get; }
internal static async Task<JoinHttpTestHost> StartAsync()
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(),
ProvisioningTestData.CreateSecrets("secret-1"),
clock.UtcNow);
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
builder.Services.AddSingleton<IWallClock>(clock);
builder.Services.AddSingleton(capabilities);
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
app.MapRendezvousContractEndpoints();
await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>();
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
return new(
app,
new HttpClient { BaseAddress = new Uri(address) },
store,
capabilities,
credential);
}
public async ValueTask DisposeAsync()
{
HttpClient.Dispose();
await _application.StopAsync();
await _application.DisposeAsync();
}
}
}
@@ -0,0 +1,286 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
public sealed class JoinAttemptServiceTests
{
[Fact]
public void CreateIsIdempotentAndScopesDistinctRoleCredentials()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key");
JoinAttemptServiceResult<CreateJoinAttemptResponse> first = fixture.Service.Create(
fixture.ClientSubject,
request);
JoinAttemptServiceResult<CreateJoinAttemptResponse> replay = fixture.Service.Create(
fixture.ClientSubject,
request);
Assert.True(first.Succeeded);
Assert.True(replay.Succeeded);
Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId);
Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle);
Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability);
Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability));
Assert.InRange(
first.Value.ClientPunchCapability.Length,
1,
ContractLimits.LiteNetLibNatTokenMaxCharacters);
HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
10,
null).Value!.Items);
Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability);
Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal);
}
[Fact]
public void SameIdempotencyKeyWithDifferentRequestConflicts()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
(RegisterSessionResponse other, _) = fixture.CreateHost();
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key");
Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded);
request.ListingId = other.ListingId;
JoinAttemptServiceResult<CreateJoinAttemptResponse> conflict = fixture.Service.Create(
fixture.ClientSubject,
request);
Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error);
}
[Fact]
public void CreationRejectsStaleIncompatibleAndCrossTenantListings()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
(RegisterSessionResponse active, _) = fixture.CreateHost();
CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId);
incompatible.ProtocolVersion = 8;
Assert.Equal(
RendezvousErrorCode.IncompatibleProtocol,
fixture.Service.Create(fixture.ClientSubject, incompatible).Error);
CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId);
otherTenant.GameId = new("other-game");
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
}
[Fact]
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
fixture.Create(registration.ListingId);
fixture.Create(registration.ListingId);
fixture.Create(registration.ListingId);
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> first = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
null);
Assert.True(first.Succeeded);
Assert.Single(first.Value!.Items);
Assert.NotNull(first.Value.NextCursor);
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> second = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
first.Value.NextCursor);
Assert.True(second.Succeeded);
Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1,
null).Error);
Assert.Equal(
RendezvousErrorCode.InvalidRequest,
fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
first.Value.NextCursor + "x").Error);
(RegisterSessionResponse other, _) = fixture.CreateHost();
Assert.Equal(
RendezvousErrorCode.InvalidRequest,
fixture.Service.BrowseForHost(
other.ListingId,
ContractLimits.ContractVersion,
other.LeaseToken,
1,
first.Value.NextCursor).Error);
}
[Fact]
public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Cancel(
created.AttemptId,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error);
Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
Assert.Empty(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
10,
null).Value!.Items);
}
[Fact]
public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
second.ClientPunchCapability,
out SecretFingerprint secondClientFingerprint));
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
first.ClientPunchCapability,
out SecretFingerprint firstClientFingerprint));
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.BindAttemptEndpoint(new(
firstStored.MediationHandle,
AttemptPeerRole.Client,
secondClientFingerprint,
new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000),
null)).Code);
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.BindAttemptEndpoint(new(
firstStored.MediationHandle,
AttemptPeerRole.Host,
firstClientFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
null)).Code);
}
[Fact]
public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
JoinAttemptServiceResult<ConnectionTicketGrant> issued = fixture.Service.IssueConnectionTicket(
introduction.Attempt);
Assert.True(issued.Succeeded);
Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket));
Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket);
Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
issued.Value.Ticket,
out SecretFingerprint ticketFingerprint));
ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint);
using ManualResetEventSlim start = new(false);
Task<StoreResult<bool>> left = Task.Run(() =>
{
start.Wait();
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
});
Task<StoreResult<bool>> right = Task.Run(() =>
{
start.Wait();
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
});
start.Set();
StoreResult<bool>[] results = await Task.WhenAll(left, right);
Assert.Single(results, static result => result.Succeeded);
Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected);
}
[Fact]
public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry()
{
EphemeralStoreOptions options = new()
{
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
};
using JoinAttemptFixture fixture = new(options);
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId);
Assert.Equal(
StoreResultCode.Conflict,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
first.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
second.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
fixture.Introduce(registration, first);
fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime);
Assert.Equal(
StoreResultCode.Expired,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
first.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
Assert.False(secondStored.ConnectionTicketConsumed);
}
[Fact]
public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15));
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
ConnectionTicketGrant ticket = Assert.IsType<ConnectionTicketGrant>(
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
Assert.Equal(created.ExpiresAt, ticket.ExpiresAt);
Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow);
Assert.DoesNotContain(
introduction.Attempt.CapabilityDerivationSalt,
introduction.Attempt.ToString(),
StringComparison.Ordinal);
}
}
@@ -0,0 +1,117 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.Sessions;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
internal sealed class JoinAttemptFixture : IDisposable
{
private int _sequence;
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
{
Sessions = new(options);
Cursors = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
}
public SessionLeaseFixture Sessions { get; }
public JoinAttemptCursorCodec Cursors { get; }
public JoinAttemptService Service { get; }
public string ClientSubject { get; }
public (RegisterSessionResponse Registration, StoredListing Listing) CreateHost(bool bindPresence = true)
{
RegisterSessionResponse registration = Sessions.Register();
if (bindPresence)
{
Assert.True(Sessions.BindPresence(registration).Succeeded);
}
StoredListing listing = Sessions.Store.GetListing(registration.ListingId, false).Value!;
return (registration, listing);
}
public CreateJoinAttemptRequest Request(
SessionListingId listingId,
string? idempotencyKey = null) => new()
{
IdempotencyKey = idempotencyKey ?? $"join-{Interlocked.Increment(ref _sequence)}",
GameId = Sessions.Scope.GameId,
EnvironmentId = Sessions.Scope.EnvironmentId,
ListingId = listingId,
ProtocolVersion = 7,
};
public CreateJoinAttemptResponse Create(
SessionListingId listingId,
string? idempotencyKey = null)
{
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = Service.Create(
ClientSubject,
Request(listingId, idempotencyKey));
Assert.True(result.Succeeded);
return Assert.IsType<CreateJoinAttemptResponse>(result.Value);
}
public StoredJoinAttempt GetAttempt(
RegisterSessionResponse registration,
JoinAttemptId attemptId)
{
Assert.True(Sessions.Capabilities.TryFingerprint(
registration.LeaseToken,
out SecretFingerprint leaseFingerprint));
IReadOnlyList<StoredJoinAttempt> attempts = Sessions.Store.BrowseHostJoinAttempts(new(
registration.ListingId,
leaseFingerprint,
ContractLimits.BrowserPageMaxItems)).Value!;
return attempts.Single(attempt => attempt.AttemptId == attemptId);
}
public IntroductionEndpoints Introduce(
RegisterSessionResponse registration,
CreateJoinAttemptResponse created)
{
StoredJoinAttempt attempt = GetAttempt(registration, created.AttemptId);
HostJoinAttempt host = Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
Assert.True(Sessions.Capabilities.TryFingerprint(
host.HostPunchCapability,
out SecretFingerprint hostFingerprint));
Assert.True(Sessions.Capabilities.TryFingerprint(
created.ClientPunchCapability,
out SecretFingerprint clientFingerprint));
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Host,
hostFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
null)).Succeeded);
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Client,
clientFingerprint,
new(AddressFamilyKind.Ipv4, "198.51.100.40", 42_000),
null)).Succeeded);
StoreResult<IntroductionEndpoints> introduced = Sessions.Store.ConsumeIntroduction(
attempt.MediationHandle);
Assert.True(introduced.Succeeded);
return introduced.Value!;
}
public void Dispose()
{
Cursors.Dispose();
Sessions.Dispose();
}
}
@@ -5,6 +5,15 @@ namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PrincipalCredentialTests
{
[Fact]
public void Base64UrlDecoderRejectsNonCanonicalTrailingBits()
{
Assert.True(Base64Url.TryDecode("AA", out byte[] canonical));
Assert.Equal(new byte[] { 0 }, canonical);
Assert.False(Base64Url.TryDecode("AB", out byte[] nonCanonical));
Assert.Empty(nonCanonical);
}
[Fact]
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
{
@@ -61,6 +61,7 @@ public sealed class SessionLeaseServiceTests
{
LeaseLifetime = TimeSpan.FromSeconds(5),
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
IdempotencyLifetime = TimeSpan.FromSeconds(6),
};
using SessionLeaseFixture fixture = new(options);
@@ -95,6 +95,8 @@ internal sealed class EphemeralStateFixture
ProtocolVersion = listing.Definition.ProtocolVersion,
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
ConnectionTicketFingerprint = Fingerprint($"ticket-{sequence}"),
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
};
}
@@ -0,0 +1,30 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.State;
public sealed class StoreResultMappingTests
{
[Fact]
public void EveryStoreResultHasOneSharedContractErrorMapping()
{
Dictionary<StoreResultCode, RendezvousErrorCode> expected = new()
{
[StoreResultCode.Success] = RendezvousErrorCode.None,
[StoreResultCode.NotFound] = RendezvousErrorCode.NotFound,
[StoreResultCode.Expired] = RendezvousErrorCode.Expired,
[StoreResultCode.Revoked] = RendezvousErrorCode.Forbidden,
[StoreResultCode.Conflict] = RendezvousErrorCode.Conflict,
[StoreResultCode.CapacityExceeded] = RendezvousErrorCode.CapacityExceeded,
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
};
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
foreach (KeyValuePair<StoreResultCode, RendezvousErrorCode> item in expected)
{
Assert.Equal(item.Value, item.Key.ToContractError());
}
}
}
@@ -1,3 +1,17 @@
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult
ENUM Accepted=1
ENUM NotFound=2
ENUM Expired=3
ENUM Rejected=4
ENUM AlreadyConsumed=5
ENUM Revoked=6
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
CTOR (System.Int32 maximumAuthorizedTickets)
METHOD FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult Consume(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
METHOD System.Void Dispose()
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
METHOD System.String ToString()
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient