Files
Rendezvous/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs
T
KyuubiYoru 94aba8a3bb
quality-gate / quality (push) Successful in 59s
feat(client): standardize connection outcomes (#13)
2026-07-16 10:18:41 +02:00

348 lines
16 KiB
C#

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.ConnectionOutcomes;
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 cancelledPollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
cancelledPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage cancelledPollResponse = await host.HttpClient.SendAsync(cancelledPollRequest);
BrowseHostJoinAttemptsResponse cancelledPoll = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await cancelledPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
HostJoinAttempt cancelledAttempt = Assert.Single(cancelledPoll.Items);
Assert.Equal(created.AttemptId, cancelledAttempt.AttemptId);
Assert.True(cancelledAttempt.IsCancelled);
}
[Fact]
public async Task OutcomeReportingIsCapabilityAuthenticatedAndIdempotentOverHttp()
{
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);
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
new CreateJoinAttemptRequest
{
IdempotencyKey = "outcome-report-1",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = 7,
},
ContractJson.Options);
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
ReportConnectionOutcomeRequest report = new()
{
Outcome = ConnectionOutcomeKind.PunchTimedOut,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
};
ReportConnectionOutcomeResponse first = await SendOutcomeAsync(
host.HttpClient,
created,
report);
ReportConnectionOutcomeResponse duplicate = await SendOutcomeAsync(
host.HttpClient,
created,
report);
Assert.True(first.Accepted);
Assert.False(first.IsDuplicate);
Assert.True(duplicate.Accepted);
Assert.True(duplicate.IsDuplicate);
Assert.Equal(
1,
host.OutcomeMetrics.GetCount(
ConnectionOutcomeKind.PunchTimedOut,
ConnectionElapsedBucket.FiveToFifteenSeconds));
using HttpRequestMessage conflictRequest = OutcomeRequest(
created,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Connected,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
});
using HttpResponseMessage conflict = await host.HttpClient.SendAsync(conflictRequest);
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
using HttpRequestMessage unauthorizedRequest = OutcomeRequest(created, report);
unauthorizedRequest.Headers.Remove("X-Rendezvous-Client-Punch-Capability");
unauthorizedRequest.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
new string('X', ContractLimits.DerivedCredentialCharacters));
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedRequest);
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
}
[Theory]
[InlineData(7u, HttpStatusCode.Gone, RendezvousErrorCode.StaleHost)]
[InlineData(8u, HttpStatusCode.Conflict, RendezvousErrorCode.IncompatibleProtocol)]
public async Task JoinCreationPreservesTypedTerminalErrorsOverHttp(
uint protocolVersion,
HttpStatusCode expectedStatus,
RendezvousErrorCode expectedError)
{
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(),
host.PublisherCredential));
using HttpResponseMessage response = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
new CreateJoinAttemptRequest
{
IdempotencyKey = $"typed-http-error-{protocolVersion}",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = protocolVersion,
},
ContractJson.Options);
Assert.Equal(expectedStatus, response.StatusCode);
ApiError error = Assert.IsType<ApiError>(
await response.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
Assert.Equal(expectedError, error.Code);
}
private static async Task<ReportConnectionOutcomeResponse> SendOutcomeAsync(
HttpClient client,
CreateJoinAttemptResponse attempt,
ReportConnectionOutcomeRequest report)
{
using HttpRequestMessage request = OutcomeRequest(attempt, report);
using HttpResponseMessage response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return Assert.IsType<ReportConnectionOutcomeResponse>(
await response.Content.ReadFromJsonAsync<ReportConnectionOutcomeResponse>(ContractJson.Options));
}
private static HttpRequestMessage OutcomeRequest(
CreateJoinAttemptResponse attempt,
ReportConnectionOutcomeRequest report)
{
HttpRequestMessage request = new(
HttpMethod.Post,
$"v1/join-attempts/{attempt.AttemptId}/outcome")
{
Content = JsonContent.Create(report, options: ContractJson.Options),
};
request.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
attempt.ClientPunchCapability);
return request;
}
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,
ConnectionOutcomeMetrics outcomeMetrics,
string publisherCredential)
{
_application = application;
HttpClient = httpClient;
Store = store;
Capabilities = capabilities;
OutcomeMetrics = outcomeMetrics;
PublisherCredential = publisherCredential;
}
internal HttpClient HttpClient { get; }
internal InMemoryEphemeralRendezvousStore Store { get; }
internal EphemeralCapabilityIssuer Capabilities { get; }
internal ConnectionOutcomeMetrics OutcomeMetrics { 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>();
ConnectionOutcomeMetrics outcomeMetrics = new();
builder.Services.AddSingleton(outcomeMetrics);
builder.Services.AddSingleton<ConnectionOutcomeService>();
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,
outcomeMetrics,
credential);
}
public async ValueTask DisposeAsync()
{
HttpClient.Dispose();
await _application.StopAsync();
await _application.DisposeAsync();
}
}
}