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( 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 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( await emptyPollResponse.Content.ReadFromJsonAsync(ContractJson.Options)); Assert.Empty(empty.Items); } 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, 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 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(); 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, credential); } public async ValueTask DisposeAsync() { HttpClient.Dispose(); await _application.StopAsync(); await _application.DisposeAsync(); } } }