From a9a2b3db355c42e89c62c92db6c56a462d8d51ff Mon Sep 17 00:00:00 2001 From: KyuubiYoru Date: Thu, 16 Jul 2026 06:06:29 +0200 Subject: [PATCH] feat: add bounded compatible session browser (#8) Closes #8 --- docs/api/rendezvous-v1.json | 79 +++++++- .../0006-compatible-session-browser.md | 51 +++++ docs/architecture/README.md | 1 + .../Http/SessionContracts.cs | 2 + .../Browser/SessionBrowserCursorCodec.cs | 181 ++++++++++++++++++ .../Browser/SessionBrowserService.cs | 157 +++++++++++++++ .../Http/ContractEndpoints.cs | 67 ++++++- src/FinalFactory.Rendezvous.Server/Program.cs | 3 + .../State/EphemeralStateContracts.cs | 4 +- .../State/InMemoryEphemeralRendezvousStore.cs | 6 +- .../Browser/SessionBrowserServiceTests.cs | 153 +++++++++++++++ .../Browser/SessionBrowserTestData.cs | 71 +++++++ .../Sessions/SessionHttpEndpointTests.cs | 20 ++ .../Contracts/v1/contracts-public-api.txt | 1 + 14 files changed, 786 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/0006-compatible-session-browser.md create mode 100644 src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserCursorCodec.cs create mode 100644 src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserService.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Browser/SessionBrowserServiceTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Browser/SessionBrowserTestData.cs diff --git a/docs/api/rendezvous-v1.json b/docs/api/rendezvous-v1.json index 90965bf..d52ee83 100644 --- a/docs/api/rendezvous-v1.json +++ b/docs/api/rendezvous-v1.json @@ -207,6 +207,13 @@ "format": "int32" } }, + { + "name": "excludeFull", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "cursor", "in": "query", @@ -226,8 +233,18 @@ } } }, - "501": { - "description": "Not Implemented", + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { @@ -530,6 +547,40 @@ "schema": { "type": "string" } + }, + { + "name": "contractVersion", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "gameId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environmentId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "protocolVersion", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "uint32" + } } ], "responses": { @@ -543,8 +594,28 @@ } } }, - "501": { - "description": "Not Implemented", + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { diff --git a/docs/architecture/0006-compatible-session-browser.md b/docs/architecture/0006-compatible-session-browser.md new file mode 100644 index 0000000..2f631ba --- /dev/null +++ b/docs/architecture/0006-compatible-session-browser.md @@ -0,0 +1,51 @@ +# ADR 0006: bounded compatible session browser + +- Status: Accepted +- Date: 2026-07-16 +- Tracking: #8 + +## Decision + +The public list endpoint requires game, environment, and exact gameplay protocol. +Region is optional, page size is 1–100, and callers may exclude sessions whose +advisory current-player count has reached the advertised maximum. Lists contain +public sessions only and only while both lease and authenticated host presence are +fresh. Unlisted sessions never appear in a list; they may be retrieved directly by +their 128-bit unguessable listing ID only when the caller also supplies the exact +game, environment, and protocol scope. + +Results use ascending opaque listing ID as a deterministic keyset. A cursor carries +the last ID plus every compatibility/filter field, a five-minute expiry, and an +HMAC-SHA256 signature under a per-process key. Tampering, expiry, or reuse with a +different tenant/protocol/region/full filter returns `InvalidRequest`. Restart +rotates the key, matching the loss of ephemeral listings. + +Pagination is a bounded live view, not a database snapshot. A record that remains +eligible and whose ID is greater than the cursor is returned exactly once. Records +removed or made stale disappear immediately. A record created after a page whose ID +sorts before that page's cursor is outside that traversal; callers refresh from the +first page to discover new sessions. This avoids skips or duplicates among stable +eligible records without retaining per-browser snapshot state. + +The store reads at most page size plus one record. The service serializes against +the 256 KiB response ceiling and shortens a page before returning it when metadata +makes the requested count too large. A continuation cursor is emitted whenever an +extra or byte-trimmed record remains. All cursor, page, metadata, property, scalar, +and collection sizes are bounded before untrusted allocation can grow without a +ceiling. + +Browser DTOs are fresh copies containing only opaque listing ID, exact compatibility, +region, visibility/trust presentation, advisory capacity, build/display labels, and +policy-validated string metadata. They contain no observed endpoint, lease, +capability, ticket, credential fingerprint, derivation salt, principal subject, or +store key. Metadata is display text: JSON encoding escapes markup, but game UI must +still render values as text and must never execute markup, interpret endpoints, or +use metadata for authorization. + +## Consequences + +- Cross-game, cross-environment, incompatible, stale, revoked, expired, unlisted, + and optionally full sessions are removed before response construction. +- Direct unlisted lookup is suitable for an out-of-band invite carrying the opaque + ID; human join codes remain future work and require their own bounded abuse model. +- Host capacity remains advisory. The host makes the final admission decision. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f008ab2..f8d93a0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -8,6 +8,7 @@ decision requires a superseding ADR and corresponding contract/test updates. - [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md) - [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md) - [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md) +- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md) - [Threat model](../security/threat-model.md) - [Security promise and test matrix](../security/control-matrix.md) - [Versioned HTTP and UDP contracts](../contracts/README.md) diff --git a/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs b/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs index ef08673..49e2148 100644 --- a/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs +++ b/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs @@ -174,6 +174,8 @@ public sealed class BrowseSessionsRequest public RegionId? RegionId { get; set; } public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems; + public bool ExcludeFull { get; set; } + public string? Cursor { get; set; } } diff --git a/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserCursorCodec.cs b/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserCursorCodec.cs new file mode 100644 index 0000000..afd61d6 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserCursorCodec.cs @@ -0,0 +1,181 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.State; + +namespace FinalFactory.Rendezvous.Server.Browser; + +internal sealed class SessionBrowserCursorCodec : IDisposable +{ + private const string Prefix = "rvc1"; + private readonly byte[] _key = RandomNumberGenerator.GetBytes(32); + private bool _disposed; + + public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now) + { + ObjectDisposedException.ThrowIf(_disposed, this); + BrowserCursorPayload payload = new() + { + GameId = query.Scope.GameId.Value, + EnvironmentId = query.Scope.EnvironmentId.Value, + ProtocolVersion = query.ProtocolVersion, + RegionId = query.RegionId?.Value, + ExcludeFull = query.ExcludeFull, + AfterListingId = after.ToString(), + ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(), + }; + string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options)); + string content = $"{Prefix}.{encoded}"; + byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content)); + try + { + string cursor = $"{content}.{EncodeBytes(signature)}"; + return ContractValidation.IsCursorValid(cursor) + ? cursor + : throw new InvalidOperationException("The browser cursor exceeds its contract limit."); + } + finally + { + CryptographicOperations.ZeroMemory(signature); + } + } + + public bool TryDecode( + string? cursor, + TenantScope scope, + uint protocolVersion, + RegionId? regionId, + bool excludeFull, + DateTimeOffset now, + out SessionListingId? after) + { + after = null; + if (cursor is null) + { + return true; + } + + if (_disposed || !ContractValidation.IsCursorValid(cursor)) + { + return false; + } + + string[] segments = cursor.Split('.'); + if (segments.Length != 3 || !string.Equals(segments[0], Prefix, StringComparison.Ordinal)) + { + return false; + } + + byte[] expected = HMACSHA256.HashData( + _key, + Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); + if (!TryDecodeBytes(segments[2], out byte[] supplied)) + { + CryptographicOperations.ZeroMemory(expected); + return false; + } + + bool validSignature = supplied.Length == expected.Length + && CryptographicOperations.FixedTimeEquals(supplied, expected); + CryptographicOperations.ZeroMemory(supplied); + CryptographicOperations.ZeroMemory(expected); + if (!validSignature || !TryDecodeBytes(segments[1], out byte[] encodedPayload)) + { + return false; + } + + BrowserCursorPayload? payload; + try + { + payload = JsonSerializer.Deserialize( + encodedPayload, + ContractJson.Options); + } + catch (JsonException) + { + payload = null; + } + finally + { + CryptographicOperations.ZeroMemory(encodedPayload); + } + + if (payload is null + || payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds() + || !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal) + || !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal) + || payload.ProtocolVersion != protocolVersion + || !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal) + || payload.ExcludeFull != excludeFull + || !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId)) + { + return false; + } + + after = listingId; + return true; + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + CryptographicOperations.ZeroMemory(_key); + } + } + + public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]"; + + private static string EncodeBytes(ReadOnlySpan bytes) => Convert + .ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static bool TryDecodeBytes(string value, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrEmpty(value) + || value.Any(static character => + character is not (>= 'A' and <= 'Z') + and not (>= 'a' and <= 'z') + and not (>= '0' and <= '9') + and not '-' + and not '_')) + { + return false; + } + + string padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" }; + try + { + bytes = Convert.FromBase64String(padded); + return true; + } + catch (FormatException) + { + return false; + } + } +} + +internal sealed class BrowserCursorPayload +{ + [JsonRequired] + public string GameId { get; set; } = string.Empty; + [JsonRequired] + public string EnvironmentId { get; set; } = string.Empty; + [JsonRequired] + public uint ProtocolVersion { get; set; } + public string? RegionId { get; set; } + [JsonRequired] + public bool ExcludeFull { get; set; } + [JsonRequired] + public string AfterListingId { get; set; } = string.Empty; + [JsonRequired] + public long ExpiresAtUnixSeconds { get; set; } +} diff --git a/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserService.cs b/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserService.cs new file mode 100644 index 0000000..08c93aa --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserService.cs @@ -0,0 +1,157 @@ +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.State; + +namespace FinalFactory.Rendezvous.Server.Browser; + +internal sealed record BrowserServiceResult(RendezvousErrorCode Error, T? Value = default) +{ + public bool Succeeded => Error == RendezvousErrorCode.None; +} + +internal sealed class SessionBrowserService( + IEphemeralRendezvousStore store, + SessionBrowserCursorCodec cursors, + IWallClock clock) +{ + public BrowserServiceResult Browse( + BrowseSessionsRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + RendezvousErrorCode validation = Validate(request); + if (validation != RendezvousErrorCode.None) + { + return new(validation); + } + + TenantScope scope = new(request.GameId, request.EnvironmentId); + if (!cursors.TryDecode( + request.Cursor, + scope, + request.ProtocolVersion, + request.RegionId, + request.ExcludeFull, + clock.UtcNow, + out SessionListingId? after)) + { + return new(RendezvousErrorCode.InvalidRequest); + } + + VisibleListingQuery query = new( + scope, + request.ProtocolVersion, + request.RegionId, + request.PageSize + 1, + after, + request.ExcludeFull); + StoreResult> found = store.BrowseVisibleListings( + query, + cancellationToken); + if (!found.Succeeded || found.Value is null) + { + return new(found.Code == StoreResultCode.ServiceUnavailable + ? RendezvousErrorCode.ServiceUnavailable + : RendezvousErrorCode.InternalError); + } + + List items = found.Value + .Take(request.PageSize) + .Select(ToContract) + .ToList(); + bool hasMore = found.Value.Count > request.PageSize; + while (items.Count > 0) + { + string? nextCursor = hasMore + ? cursors.Encode(query, items[^1].ListingId, clock.UtcNow) + : null; + BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor }; + if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length + <= ContractLimits.BrowserResponseMaxBytes) + { + return new(RendezvousErrorCode.None, response); + } + + items.RemoveAt(items.Count - 1); + hasMore = true; + } + + return new(RendezvousErrorCode.None, new BrowseSessionsResponse()); + } + + public BrowserServiceResult Get( + SessionListingId listingId, + GameId gameId, + EnvironmentId environmentId, + uint protocolVersion, + CancellationToken cancellationToken = default) + { + if (listingId.Value == Guid.Empty + || string.IsNullOrEmpty(gameId.Value) + || string.IsNullOrEmpty(environmentId.Value) + || protocolVersion == 0) + { + return new(RendezvousErrorCode.InvalidRequest); + } + + StoreResult found = store.GetListing(listingId, true, cancellationToken); + if (!found.Succeeded || found.Value is null) + { + return new(found.Code == StoreResultCode.ServiceUnavailable + ? RendezvousErrorCode.ServiceUnavailable + : RendezvousErrorCode.NotFound); + } + + StoredListing listing = found.Value; + if (listing.Definition.Scope != new TenantScope(gameId, environmentId) + || listing.Definition.ProtocolVersion != protocolVersion) + { + return new(RendezvousErrorCode.NotFound); + } + + return new(RendezvousErrorCode.None, new GetSessionResponse + { + Session = ToContract(listing), + }); + } + + private static RendezvousErrorCode Validate(BrowseSessionsRequest request) + { + RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion); + if (version != RendezvousErrorCode.None) + { + return version; + } + + return string.IsNullOrEmpty(request.GameId.Value) + || string.IsNullOrEmpty(request.EnvironmentId.Value) + || request.ProtocolVersion == 0 + || (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)) + || !ContractValidation.IsPageSizeValid(request.PageSize) + || !ContractValidation.IsCursorValid(request.Cursor) + ? RendezvousErrorCode.InvalidRequest + : RendezvousErrorCode.None; + } + + private static SessionListing ToContract(StoredListing stored) => new() + { + ListingId = stored.Definition.ListingId, + GameId = stored.Definition.Scope.GameId, + EnvironmentId = stored.Definition.Scope.EnvironmentId, + RegionId = stored.Definition.RegionId, + ProtocolVersion = stored.Definition.ProtocolVersion, + BuildVersion = stored.Definition.BuildVersion, + DisplayName = stored.Definition.DisplayName, + Visibility = stored.Definition.Visibility, + PublisherTrustMode = stored.Definition.TrustMode, + Capacity = new() + { + CurrentPlayers = stored.Definition.CurrentPlayers, + MaximumPlayers = stored.Definition.MaximumPlayers, + }, + Metadata = stored.Definition.Metadata.ToDictionary( + static item => item.Key, + static item => item.Value, + StringComparer.Ordinal), + }; +} diff --git a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs index 06bd39d..4049ec9 100644 --- a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs +++ b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs @@ -1,4 +1,5 @@ using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; @@ -55,11 +56,14 @@ internal static class ContractEndpoints .WithName("DeleteSession"); sessions.MapGet("/", BrowseSessions) .Produces() - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("BrowseSessions"); sessions.MapGet("/{listingId}", GetSession) .Produces() - .Produces(NotImplementedStatus) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("GetSession"); sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) .Produces() @@ -200,9 +204,64 @@ internal static class ContractEndpoints [FromQuery] uint protocolVersion, [FromQuery] string? regionId, [FromQuery] int? pageSize, - [FromQuery] string? cursor) => NotImplemented(); + [FromQuery] bool? excludeFull, + [FromQuery] string? cursor, + [FromServices] SessionBrowserService browser, + CancellationToken cancellationToken) + { + if (!GameId.TryParse(gameId, out GameId parsedGameId) + || !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId) + || (regionId is not null && !RegionId.TryParse(regionId, out _))) + { + return Error(RendezvousErrorCode.InvalidRequest); + } - private static IResult GetSession(SessionListingId listingId) => NotImplemented(); + BrowserServiceResult result = browser.Browse(new() + { + ContractVersion = contractVersion, + GameId = parsedGameId, + EnvironmentId = parsedEnvironmentId, + ProtocolVersion = protocolVersion, + RegionId = regionId is null ? null : new RegionId(regionId), + PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems, + ExcludeFull = excludeFull ?? false, + Cursor = cursor, + }, cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } + + private static IResult GetSession( + SessionListingId listingId, + [FromQuery] int contractVersion, + [FromQuery] string gameId, + [FromQuery] string environmentId, + [FromQuery] uint protocolVersion, + [FromServices] SessionBrowserService browser, + CancellationToken cancellationToken) + { + if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None) + { + return Error(RendezvousErrorCode.UnsupportedContractVersion); + } + + if (!GameId.TryParse(gameId, out GameId parsedGameId) + || !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)) + { + return Error(RendezvousErrorCode.InvalidRequest); + } + + BrowserServiceResult result = browser.Get( + listingId, + parsedGameId, + parsedEnvironmentId, + protocolVersion, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } private static IResult BrowseHostJoinAttempts( SessionListingId listingId, diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs index d335395..3e66095 100644 --- a/src/FinalFactory.Rendezvous.Server/Program.cs +++ b/src/FinalFactory.Rendezvous.Server/Program.cs @@ -1,5 +1,6 @@ using System.Net; using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; @@ -119,6 +120,8 @@ else builder.Services.AddSingleton(sessionCapabilities); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(new ProvisioningReadiness(true)); } diff --git a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs index 411c22a..9756d2b 100644 --- a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs +++ b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs @@ -223,7 +223,9 @@ internal sealed record VisibleListingQuery( TenantScope Scope, uint ProtocolVersion, RegionId? RegionId, - int MaximumResults = ContractLimits.BrowserPageMaxItems); + int MaximumResults = ContractLimits.BrowserPageMaxItems, + SessionListingId? AfterListingId = null, + bool ExcludeFull = false); internal enum AttemptPeerRole { diff --git a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs index b67ab6e..4a193c9 100644 --- a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs +++ b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs @@ -301,7 +301,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto || query.ProtocolVersion == 0 || (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value)) || query.MaximumResults <= 0 - || query.MaximumResults > ContractLimits.BrowserPageMaxItems) + || query.MaximumResults > ContractLimits.BrowserPageMaxItems + 1) { throw new ArgumentOutOfRangeException(nameof(query)); } @@ -311,6 +311,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto && entry.Definition.ProtocolVersion == query.ProtocolVersion && entry.Definition.Visibility == ListingVisibility.Public && (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value) + && (!query.AfterListingId.HasValue + || entry.Definition.ListingId.Value.CompareTo(query.AfterListingId.Value.Value) > 0) + && (!query.ExcludeFull + || entry.Definition.CurrentPlayers < entry.Definition.MaximumPlayers) && _presence.ContainsKey(entry.Definition.HostPresenceHandle)) .OrderBy(static entry => entry.Definition.ListingId.Value) .Take(query.MaximumResults) diff --git a/tests/FinalFactory.Rendezvous.Tests/Browser/SessionBrowserServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/Browser/SessionBrowserServiceTests.cs new file mode 100644 index 0000000..548a98d --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Browser/SessionBrowserServiceTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Browser; +using FinalFactory.Rendezvous.Server.State; + +namespace FinalFactory.Rendezvous.Tests.Browser; + +public sealed class SessionBrowserServiceTests +{ + [Fact] + public void ListEnforcesTenantProtocolPresenceVisibilityAndAvailabilityFilters() + { + using SessionBrowserFixture fixture = new(); + StoredListing eligible = fixture.Add(); + fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId)); + fixture.Add(scope: new(fixture.Scope.GameId, new("other-env"))); + fixture.Add(protocolVersion: 8); + fixture.Add(regionId: new("us-east")); + fixture.Add(visibility: ListingVisibility.Unlisted); + fixture.Add(fresh: false); + fixture.Add(currentPlayers: 8, maximumPlayers: 8); + BrowseSessionsRequest request = fixture.Request(); + request.ExcludeFull = true; + + BrowserServiceResult result = fixture.Browser.Browse(request); + + Assert.True(result.Succeeded); + Assert.Collection(result.Value!.Items, item => Assert.Equal(eligible.Definition.ListingId, item.ListingId)); + } + + [Fact] + public void UnguessableIdRetrievalAllowsFreshUnlistedOnlyWithinExactScope() + { + using SessionBrowserFixture fixture = new(); + StoredListing unlisted = fixture.Add(visibility: ListingVisibility.Unlisted); + + Assert.True(fixture.Browser.Get( + unlisted.Definition.ListingId, + fixture.Scope.GameId, + fixture.Scope.EnvironmentId, + 7).Succeeded); + Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get( + unlisted.Definition.ListingId, + new("other-game"), + fixture.Scope.EnvironmentId, + 7).Error); + Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get( + unlisted.Definition.ListingId, + fixture.Scope.GameId, + fixture.Scope.EnvironmentId, + 8).Error); + fixture.Clock.Advance(TimeSpan.FromSeconds(20)); + Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get( + unlisted.Definition.ListingId, + fixture.Scope.GameId, + fixture.Scope.EnvironmentId, + 7).Error); + } + + [Fact] + public void KeysetCursorReturnsStableRecordsOnceAndRejectsTamperingOrRescoping() + { + using SessionBrowserFixture fixture = new(); + for (int index = 0; index < 7; index++) + { + fixture.Add(); + } + + BrowseSessionsRequest request = fixture.Request(pageSize: 2); + List seen = []; + do + { + BrowseSessionsResponse page = fixture.Browser.Browse(request).Value!; + seen.AddRange(page.Items.Select(static item => item.ListingId)); + request.Cursor = page.NextCursor; + } + while (request.Cursor is not null); + + Assert.Equal(7, seen.Count); + Assert.Equal(7, seen.Distinct().Count()); + Assert.Equal(seen.OrderBy(static id => id.Value), seen); + + BrowseSessionsRequest tampered = fixture.Request(pageSize: 2); + tampered.Cursor = fixture.Browser.Browse(tampered).Value!.NextCursor + "A"; + Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(tampered).Error); + BrowseSessionsRequest rescoped = fixture.Request(pageSize: 2); + rescoped.Cursor = fixture.Browser.Browse(fixture.Request(pageSize: 2)).Value!.NextCursor; + rescoped.ExcludeFull = true; + Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(rescoped).Error); + + BrowseSessionsRequest expired = fixture.Request(pageSize: 2); + expired.Cursor = fixture.Browser.Browse(expired).Value!.NextCursor; + fixture.Clock.Advance(TimeSpan.FromMinutes(5)); + Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(expired).Error); + } + + [Fact] + public void ResponseByteBudgetTrimsLargePagesAndContinuesWithCursor() + { + using SessionBrowserFixture fixture = new(); + Dictionary metadata = Enumerable.Range(0, 14).ToDictionary( + static index => $"key-{index}", + static index => new string((char)('a' + index % 26), 256), + EqualityComparer.Default); + for (int index = 0; index < 100; index++) + { + fixture.Add(metadata: metadata); + } + + BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!; + int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length; + + Assert.InRange(encodedBytes, 1, ContractLimits.BrowserResponseMaxBytes); + Assert.NotEmpty(response.Items); + Assert.NotNull(response.NextCursor); + Assert.True(response.Items.Count < 100); + } + + [Fact] + public void PresentationMetadataIsJsonEscapedAndResponseHasNoConnectionSecrets() + { + using SessionBrowserFixture fixture = new(); + fixture.Add(metadata: new Dictionary(StringComparer.Ordinal) + { + ["mode"] = "co-op", + ["map"] = "", + }); + + BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!; + string json = JsonSerializer.Serialize(response, ContractJson.Options); + + Assert.DoesNotContain("", Assert.Single(response.Items).Metadata["map"]); + } + + [Fact] + public void RevokedListingDisappearsBeforeAnotherReadPathCanObserveIt() + { + using SessionBrowserFixture fixture = new(); + StoredListing listing = fixture.Add(); + fixture.Store.RevokeListing(listing.Definition.ListingId); + + Assert.Empty(fixture.Browser.Browse(fixture.Request()).Value!.Items); + Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get( + listing.Definition.ListingId, + fixture.Scope.GameId, + fixture.Scope.EnvironmentId, + 7).Error); + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Browser/SessionBrowserTestData.cs b/tests/FinalFactory.Rendezvous.Tests/Browser/SessionBrowserTestData.cs new file mode 100644 index 0000000..82de9e0 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Browser/SessionBrowserTestData.cs @@ -0,0 +1,71 @@ +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Browser; +using FinalFactory.Rendezvous.Server.State; +using FinalFactory.Rendezvous.Tests.State; + +namespace FinalFactory.Rendezvous.Tests.Browser; + +internal sealed class SessionBrowserFixture : IDisposable +{ + private readonly EphemeralStateFixture _state = new(); + + public SessionBrowserFixture() + { + Cursors = new(); + Browser = new(_state.Store, Cursors, _state.Clock); + } + + public InMemoryEphemeralRendezvousStore Store => _state.Store; + public ManualRendezvousClock Clock => _state.Clock; + public SessionBrowserCursorCodec Cursors { get; } + public SessionBrowserService Browser { get; } + public TenantScope Scope => _state.Scope; + + public StoredListing Add( + TenantScope? scope = null, + uint protocolVersion = 7, + RegionId? regionId = null, + ListingVisibility visibility = ListingVisibility.Public, + bool fresh = true, + int currentPlayers = 1, + int maximumPlayers = 8, + IReadOnlyDictionary? metadata = null) + { + CreateListingCommand seed = _state.ListingCommand(); + CreateListingCommand command = seed with + { + Listing = seed.Listing with + { + Scope = scope ?? Scope, + ProtocolVersion = protocolVersion, + RegionId = regionId ?? seed.Listing.RegionId, + Visibility = visibility, + CurrentPlayers = currentPlayers, + MaximumPlayers = maximumPlayers, + Metadata = metadata ?? seed.Listing.Metadata, + }, + }; + StoredListing listing = Store.CreateListing(command).Value!; + if (fresh) + { + listing = Store.BindHostPresence(new( + command.Listing.HostPresenceHandle, + command.Listing.HostPresenceFingerprint, + EphemeralStateFixture.PublicEndpoint(40_000), + null)).Value!; + } + + return listing; + } + + public BrowseSessionsRequest Request(int pageSize = 100) => new() + { + GameId = Scope.GameId, + EnvironmentId = Scope.EnvironmentId, + ProtocolVersion = 7, + RegionId = new("eu-central"), + PageSize = pageSize, + }; + + public void Dispose() => Cursors.Dispose(); +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionHttpEndpointTests.cs b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionHttpEndpointTests.cs index caaef87..e59f6a3 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionHttpEndpointTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionHttpEndpointTests.cs @@ -4,6 +4,7 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; @@ -52,6 +53,8 @@ public sealed class SessionHttpEndpointTests builder.Services.AddSingleton(capabilities); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); await using WebApplication app = builder.Build(); app.UseExceptionHandler(); app.MapRendezvousContractEndpoints(); @@ -107,6 +110,23 @@ public sealed class SessionHttpEndpointTests ContractJson.Options); Assert.NotNull(session); Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString); + Assert.True(capabilities.TryFingerprint( + session.HostPresenceCapability, + out SecretFingerprint presenceFingerprint)); + store.BindHostPresence(new( + session.HostPresenceHandle, + presenceFingerprint, + new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000), + null)); + + BrowseSessionsResponse? browser = await client.GetFromJsonAsync( + "/v1/sessions?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7®ionId=eu-central&pageSize=10&excludeFull=true", + ContractJson.Options); + Assert.Equal(session.ListingId, Assert.Single(browser!.Items).ListingId); + GetSessionResponse? direct = await client.GetFromJsonAsync( + $"/v1/sessions/{session.ListingId}?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7", + ContractJson.Options); + Assert.Equal(session.ListingId, direct!.Session.ListingId); HttpResponseMessage renewed = await client.PostAsJsonAsync( $"/v1/sessions/{session.ListingId}/renew", diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt index e20fad4..1642b76 100644 --- a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt @@ -18,6 +18,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest PROP System.Int32 ContractVersion {get;set;} PROP System.String Cursor {get;set;} PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;} + PROP System.Boolean ExcludeFull {get;set;} PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;} PROP System.Int32 PageSize {get;set;} PROP System.UInt32 ProtocolVersion {get;set;}