using System.Diagnostics; using System.Net; using System.Net.Sockets; using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Http; 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.Client; public sealed class RendezvousClientIntegrationTests { [Fact] public async Task RestartReturnsTypedUnavailabilityThenAllowsHostReregistration() { int port = ReserveTcpPort(); string address = $"http://127.0.0.1:{port}"; using HttpClient client = new() { BaseAddress = new Uri(address) }; RendezvousClientOptions noRetry = new() { MaximumSafeRetries = 0, RequestTimeout = TimeSpan.FromSeconds(1), }; ClientTestHost first = await ClientTestHost.StartAsync(address); RendezvousPublisherClient publisher = new(client, noRetry); RendezvousClientResult registered = await publisher.RegisterAsync( CreateRegistration(100), first.PublisherCredential); PublishedSession initialSession = AssertSuccess(registered); BindPresence(first, initialSession, 41_100); RendezvousSessionBrowserClient browser = new(client, noRetry); BrowseSessionsResponse beforeRestart = AssertSuccess(await browser.BrowseAsync(BrowseRequest())); Assert.Equal(initialSession.ListingId, Assert.Single(beforeRestart.Items).ListingId); Stopwatch restart = Stopwatch.StartNew(); await first.DisposeAsync(); RendezvousClientResult unavailable = await browser.BrowseAsync(BrowseRequest()); Assert.False(unavailable.IsSuccess); Assert.Equal(RendezvousErrorCode.ServiceUnavailable, unavailable.Error); await using ClientTestHost second = await ClientTestHost.StartAsync(address); RendezvousClientResult reregistered = await publisher.RegisterAsync( CreateRegistration(101), second.PublisherCredential); PublishedSession replacementSession = AssertSuccess(reregistered); Assert.NotEqual(initialSession.ListingId, replacementSession.ListingId); BindPresence(second, replacementSession, 41_101); BrowseSessionsResponse afterRestart = AssertSuccess(await browser.BrowseAsync(BrowseRequest())); Assert.Equal(replacementSession.ListingId, Assert.Single(afterRestart.Items).ListingId); Assert.DoesNotContain(afterRestart.Items, item => item.ListingId == initialSession.ListingId); Assert.True( restart.Elapsed < TimeSpan.FromSeconds(5), $"Local restart and host re-registration took {restart.Elapsed}."); } [Fact] public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging() { await using ClientTestHost host = await ClientTestHost.StartAsync(); RendezvousPublisherClient publisher = new(host.HttpClient); RendezvousSessionBrowserClient browser = new(host.HttpClient); List sessions = []; for (int index = 0; index < 3; index++) { RendezvousClientResult registered = await publisher.RegisterAsync( CreateRegistration(index), host.PublisherCredential); PublishedSession session = AssertSuccess(registered); sessions.Add(session); Assert.True(host.Capabilities.TryFingerprint( session.HostPresenceCapability, out SecretFingerprint fingerprint)); StoreResult bound = host.Store.BindHostPresence(new( session.HostPresenceHandle, fingerprint, new(AddressFamilyKind.Ipv4, $"203.0.113.{80 + index}", 41_000 + index), null)); Assert.Equal(StoreResultCode.Success, bound.Code); } PublishedSession first = sessions[0]; RendezvousClientResult renewed = await publisher.RenewAsync( first, host.PublisherCredential); Assert.True(renewed.IsSuccess, renewed.Message); Assert.Equal(renewed.Value!.ExpiresAt, first.ExpiresAt); UpdateSessionRequest update = new() { BuildVersion = "2.0.0", DisplayName = "SDK host updated", Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 }, Metadata = new() { ["mode"] = "online-coop" }, }; RendezvousClientResult updated = await publisher.UpdateAsync( first, update, host.PublisherCredential); Assert.True(updated.IsSuccess, updated.Message); BrowseSessionsRequest browseRequest = new() { GameId = new("space-game"), EnvironmentId = new("production"), RegionId = new("eu-central"), ProtocolVersion = 7, PageSize = 1, ExcludeFull = true, }; IReadOnlyList listings = AssertSuccess( await browser.BrowseAllAsync(browseRequest)); Assert.Equal(3, listings.Count); Assert.Equal("SDK host updated", listings.Single(item => item.ListingId == first.ListingId).DisplayName); GetSessionResponse direct = AssertSuccess(await browser.GetAsync( first.ListingId, new("space-game"), new("production"), 7)); Assert.Equal("2.0.0", direct.Session.BuildVersion); foreach (PublishedSession session in sessions) { RendezvousClientResult deregistered = await publisher.DeregisterAsync( session, host.PublisherCredential); Assert.True(deregistered.IsSuccess, deregistered.Message); Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(session.ListingId, false).Code); } } [Fact] public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections() { await using ClientTestHost host = await ClientTestHost.StartAsync(); RendezvousPublisherClient publisher = new(host.HttpClient); RendezvousSessionBrowserClient browser = new(host.HttpClient); PublishedSession session = AssertSuccess(await publisher.RegisterAsync( CreateRegistration(200), host.PublisherCredential)); BindPresence(host, session, 41_200); BrowseSessionsRequest request = BrowseRequest(); BrowseSessionsResponse snapshot = AssertSuccess(await browser.BrowseAsync(request)); Assert.False(string.IsNullOrWhiteSpace(snapshot.StreamCursor)); using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); await using (IAsyncEnumerator> invalid = browser .StreamAsync(request, CorruptCursor(snapshot.StreamCursor), timeout.Token) .GetAsyncEnumerator(timeout.Token)) { Assert.True(await invalid.MoveNextAsync()); Assert.Equal(SessionStreamEventKind.Reset, AssertSuccess(invalid.Current).Kind); Assert.False(await invalid.MoveNextAsync()); } await using IAsyncEnumerator> events = browser .StreamAsync(request, snapshot.StreamCursor, timeout.Token) .GetAsyncEnumerator(timeout.Token); Task upsertPending = events.MoveNextAsync().AsTask(); Assert.True((await publisher.UpdateAsync( session, new UpdateSessionRequest { BuildVersion = "2.0.0", DisplayName = "Live update", Capacity = new() { CurrentPlayers = 3, MaximumPlayers = 8 }, Metadata = new() { ["mode"] = "online-coop" }, }, host.PublisherCredential, timeout.Token)).IsSuccess); Assert.True(await upsertPending); SessionStreamEvent upsert = AssertSuccess(events.Current); Assert.Equal(SessionStreamEventKind.SessionUpsert, upsert.Kind); Assert.Equal("Live update", upsert.Session!.DisplayName); await using (IAsyncEnumerator> replay = browser .StreamAsync(request, snapshot.StreamCursor, timeout.Token) .GetAsyncEnumerator(timeout.Token)) { Assert.True(await replay.MoveNextAsync()); SessionStreamEvent replayed = AssertSuccess(replay.Current); Assert.Equal(SessionStreamEventKind.SessionUpsert, replayed.Kind); Assert.Equal(upsert.Cursor, replayed.Cursor); Assert.Equal("Live update", replayed.Session!.DisplayName); } Task removePending = events.MoveNextAsync().AsTask(); Assert.True((await publisher.DeregisterAsync( session, host.PublisherCredential, timeout.Token)).IsSuccess); Assert.True(await removePending); SessionStreamEvent remove = AssertSuccess(events.Current); Assert.Equal(SessionStreamEventKind.SessionRemove, remove.Kind); Assert.Equal(session.ListingId, remove.ListingId); } private static string CorruptCursor(string cursor) { char replacement = cursor[^1] == 'a' ? 'b' : 'a'; return cursor[..^1] + replacement; } private static T AssertSuccess(RendezvousClientResult result) { Assert.True(result.IsSuccess, result.Message); return Assert.IsAssignableFrom(result.Value); } private static void BindPresence(ClientTestHost host, PublishedSession session, int port) { Assert.True(host.Capabilities.TryFingerprint( session.HostPresenceCapability, out SecretFingerprint fingerprint)); Assert.Equal(StoreResultCode.Success, host.Store.BindHostPresence(new( session.HostPresenceHandle, fingerprint, new(AddressFamilyKind.Ipv4, "203.0.113.80", port), null)).Code); } private static BrowseSessionsRequest BrowseRequest() => new() { GameId = new("space-game"), EnvironmentId = new("production"), RegionId = new("eu-central"), ProtocolVersion = 7, PageSize = 10, }; private static RegisterSessionRequest CreateRegistration(int index) => new() { IdempotencyKey = $"sdk-integration-{index}", GameId = new("space-game"), EnvironmentId = new("production"), RegionId = new("eu-central"), ProtocolVersion = 7, BuildVersion = "1.0.0", DisplayName = $"SDK host {index}", Visibility = ListingVisibility.Public, Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 }, Metadata = new() { ["mode"] = "online-coop" }, }; private sealed class ClientTestHost : IAsyncDisposable { private readonly WebApplication _application; private ClientTestHost( 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(string? bindAddress = null) { ManualRendezvousClock clock = new(ProvisioningTestData.Now); EphemeralStoreOptions stateOptions = new(); SessionChangeJournal changes = new(new SessionChangeJournalOptions()); InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes); 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(bindAddress ?? "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.AddOptions(); builder.Services.AddSingleton(); builder.Services.AddSingleton(provisioning); 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(changes); builder.Services.AddSingleton(); builder.Services.AddSingleton(); WebApplication app = builder.Build(); app.UseExceptionHandler(); app.UseMiddleware(); app.MapRendezvousContractEndpoints(); await app.StartAsync(); IServer server = app.Services.GetRequiredService(); string serviceAddress = Assert.Single(server.Features.Get()!.Addresses); return new( app, new HttpClient { BaseAddress = new Uri(serviceAddress) }, store, capabilities, credential); } public async ValueTask DisposeAsync() { HttpClient.Dispose(); await _application.StopAsync(); await _application.DisposeAsync(); } } private static int ReserveTcpPort() { TcpListener listener = new(IPAddress.Loopback, 0); listener.Start(); int port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } }