351 lines
16 KiB
C#
351 lines
16 KiB
C#
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<PublishedSession> 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<BrowseSessionsResponse> unavailable = await browser.BrowseAsync(BrowseRequest());
|
|
Assert.False(unavailable.IsSuccess);
|
|
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, unavailable.Error);
|
|
|
|
await using ClientTestHost second = await ClientTestHost.StartAsync(address);
|
|
RendezvousClientResult<PublishedSession> 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<PublishedSession> sessions = [];
|
|
|
|
for (int index = 0; index < 3; index++)
|
|
{
|
|
RendezvousClientResult<PublishedSession> 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<StoredListing> 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<RenewLeaseResponse> 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<bool> 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<SessionListing> 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<bool> 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<RendezvousClientResult<SessionStreamEvent>> 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<RendezvousClientResult<SessionStreamEvent>> events = browser
|
|
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
|
.GetAsyncEnumerator(timeout.Token);
|
|
Task<bool> 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<RendezvousClientResult<SessionStreamEvent>> 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<bool> 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<T>(RendezvousClientResult<T> result)
|
|
{
|
|
Assert.True(result.IsSuccess, result.Message);
|
|
return Assert.IsAssignableFrom<T>(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<ClientTestHost> 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<RouteHandlerOptions>(static options =>
|
|
options.ThrowOnBadRequest = true);
|
|
builder.Services.AddProblemDetails();
|
|
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
|
builder.Services.AddOptions<AbuseProtectionOptions>();
|
|
builder.Services.AddSingleton<AbuseProtectionService>();
|
|
builder.Services.AddSingleton(provisioning);
|
|
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<SessionStreamCursorCodec>();
|
|
builder.Services.AddSingleton(changes);
|
|
builder.Services.AddSingleton<SessionBrowserService>();
|
|
builder.Services.AddSingleton<SessionStreamService>();
|
|
|
|
WebApplication app = builder.Build();
|
|
app.UseExceptionHandler();
|
|
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
|
app.MapRendezvousContractEndpoints();
|
|
await app.StartAsync();
|
|
IServer server = app.Services.GetRequiredService<IServer>();
|
|
string serviceAddress = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.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;
|
|
}
|
|
}
|