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( await createdResponse.Content.ReadFromJsonAsync(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( await pollResponse.Content.ReadFromJsonAsync(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( await missingCapability.Content.ReadFromJsonAsync(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( await cancelledPollResponse.Content.ReadFromJsonAsync(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( await createdResponse.Content.ReadFromJsonAsync(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( await response.Content.ReadFromJsonAsync(ContractJson.Options)); Assert.Equal(expectedError, error.Code); } private static async Task 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( await response.Content.ReadFromJsonAsync(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(RendezvousClientResult result) { Assert.True(result.IsSuccess, result.Message); return Assert.IsAssignableFrom(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 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(static options => options.ThrowOnBadRequest = true); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); builder.Services.AddSingleton(provisioning); builder.Services.AddSingleton(provisioning.Policies); builder.Services.AddSingleton(provisioning.Credentials); builder.Services.AddSingleton(provisioning.PublisherAuthorization); builder.Services.AddSingleton(store); builder.Services.AddSingleton(clock); builder.Services.AddSingleton(capabilities); builder.Services.AddSingleton(capabilities); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); ConnectionOutcomeMetrics outcomeMetrics = new(); builder.Services.AddSingleton(outcomeMetrics); builder.Services.AddSingleton(); WebApplication app = builder.Build(); app.UseExceptionHandler(); app.MapRendezvousContractEndpoints(); await app.StartAsync(); IServer server = app.Services.GetRequiredService(); string address = Assert.Single(server.Features.Get()!.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(); } } }