From 4021f7c6e0d19fa64610959f1bc0aaf25262518e Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:04:45 +0300 Subject: [PATCH] feat: Update RoomInstance handling to serialize roomInstanceId as string for JSON compatibility; add tests to ensure correct behavior --- .../AdminInstancePullTests.cs | 154 ++++++++++++++++++ .../Controllers/Admin/AdminController.cs | 34 +++- DorkNet.Server/admin-ui/src/lib/types.ts | 11 +- 3 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 DorkNet.Server.Tests/AdminInstancePullTests.cs diff --git a/DorkNet.Server.Tests/AdminInstancePullTests.cs b/DorkNet.Server.Tests/AdminInstancePullTests.cs new file mode 100644 index 0000000..d23f174 --- /dev/null +++ b/DorkNet.Server.Tests/AdminInstancePullTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using DorkNet.Server.Controllers.Match; +using DorkNet.Server.Data; +using DorkNet.Server.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Tests; + +/// +/// Regression: admin "pull a player into an instance" broke because the +/// instance id (an int64 well past JS's 2^53 safe-integer range) was +/// emitted as a JSON number. The admin SPA's JSON.parse rounded it to the +/// nearest double, so the pull URL targeted a non-existent instance and +/// the server returned instance_not_active. The fix serializes the +/// id as a STRING so the exact digits survive the browser round-trip. +/// +public sealed class AdminInstancePullTests : IClassFixture +{ + // A dorm-style instance id past 2^53 — the value class that a JS + // Number cannot represent exactly. + private const long BigInstanceId = 7597291079085522944L; + + private readonly DorkNetServerFactory _factory; + + public AdminInstancePullTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Instances_list_emits_id_as_string_and_pull_with_exact_id_succeeds() + { + using var setup = ApiClient(); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + long roomId; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + // Promote the caller to admin (AdminOnly reads IsAdmin from DB). + var adminRow = await db.Players.FirstAsync(p => p.Id == admin.PlayerId); + adminRow.IsAdmin = true; + + roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id; + await db.SaveChangesAsync(); + + // Put the streamer in a live instance carrying the huge id. + var presence = scope.ServiceProvider.GetRequiredService(); + presence.SetRoom(streamer.PlayerId, new RoomInstanceDto + { + RoomInstanceId = BigInstanceId, + RoomId = roomId, + SubRoomId = 0, + PhotonRoomId = "^teststreamerinstance", + PhotonRegionId = "us", + MaxCapacity = 8, + }); + presence.MarkActive(streamer.PlayerId); + } + + using var adminClient = ApiClient(admin); + adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}"); + + // The list must emit roomInstanceId as a STRING equal to the exact + // digits — never a number (which would already be rounded here). + var list = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/instances"); + var row = list.EnumerateArray() + .First(r => r.GetProperty("roomInstanceId").GetString() == BigInstanceId.ToString()); + Assert.Equal(JsonValueKind.String, row.GetProperty("roomInstanceId").ValueKind); + + // Pull the second player in using the EXACT id from the list. This + // is what the SPA does; with the string round-trip the digits are + // intact and the instance matches (pre-fix: 404 instance_not_active). + var idFromList = row.GetProperty("roomInstanceId").GetString()!; + using var pull = await adminClient.PostAsync( + $"/api/admin/v1/rooms/{roomId}/instances/{idFromList}/pull", + new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }), + Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.OK, pull.StatusCode); + using var body = await JsonDocument.ParseAsync(await pull.Content.ReadAsStreamAsync()); + Assert.True(body.RootElement.GetProperty("ok").GetBoolean()); + // The pulled player now shares the streamer's instance. + await using var verify = _factory.Services.CreateAsyncScope(); + var presenceCheck = verify.ServiceProvider.GetRequiredService(); + Assert.Equal(BigInstanceId, presenceCheck.GetRoom(pulled.PlayerId)!.RoomInstanceId); + } + + [Fact] + public async Task Pull_with_precision_mangled_id_is_rejected() + { + using var setup = ApiClient(); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + long roomId; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true; + roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id; + await db.SaveChangesAsync(); + var presence = scope.ServiceProvider.GetRequiredService(); + presence.SetRoom(streamer.PlayerId, new RoomInstanceDto + { + RoomInstanceId = BigInstanceId, + RoomId = roomId, + PhotonRoomId = "^teststreamerinstance2", + PhotonRegionId = "us", + MaxCapacity = 8, + }); + presence.MarkActive(streamer.PlayerId); + } + + using var adminClient = ApiClient(admin); + adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}"); + + // The value the admin SPA's JSON.parse actually produced for this + // id (from the bug report log) — the exact failure mode. + const long mangled = 7597291079085523000L; + Assert.NotEqual(BigInstanceId, mangled); + using var pull = await adminClient.PostAsync( + $"/api/admin/v1/rooms/{roomId}/instances/{mangled}/pull", + new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }), + Encoding.UTF8, "application/json")); + Assert.Equal(HttpStatusCode.NotFound, pull.StatusCode); + } + + private HttpClient ApiClient(GameClientSession? session = null) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } + + private static async Task GetJsonAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.Clone(); + } +} diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index bb2e97f..f4f8637 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1285,7 +1285,13 @@ public class AdminController( master = i.pids.OrderBy(p => p).FirstOrDefault(); return new { - roomInstanceId = i.roomInstanceId, + // Instance ids are int64 values well past JS's safe-integer + // range (2^53); if emitted as a JSON number the admin SPA's + // JSON.parse rounds them to the nearest double and the pull / + // close URLs then target a non-existent instance + // ("instance_not_active"). Emit as a STRING so the browser + // round-trips the exact digits. + roomInstanceId = i.roomInstanceId.ToString(), roomId = i.roomId, subRoomId = i.subRoomId, roomName = i.roomName, @@ -1341,7 +1347,7 @@ public class AdminController( sample); await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}"); await db.SaveChangesAsync(); - return Ok(new { ok = true, instanceId, roomInstanceId = sample.RoomInstanceId }); + return Ok(new { ok = true, instanceId = instanceId.ToString(), roomInstanceId = sample.RoomInstanceId.ToString() }); } /// POST api/admin/v1/rooms/{id}/instances/{instanceId}/close @@ -2042,7 +2048,9 @@ public class AdminController( room.RoomId, Name = roomName ?? room.Name, room.SubRoomId, - room.RoomInstanceId, + // String — int64 past JS's 2^53 safe range (see the + // /instances comment). + RoomInstanceId = room.RoomInstanceId.ToString(), room.PhotonRoomId, room.PhotonRegionId, room.IsPrivate, @@ -2887,7 +2895,7 @@ public class AdminController( { g = new InstanceGroup { - RoomInstanceId = room.RoomInstanceId, + RoomInstanceId = room.RoomInstanceId.ToString(), RoomId = room.RoomId, SubRoomId = room.SubRoomId, RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"), @@ -2916,7 +2924,10 @@ public class AdminController( private sealed class InstanceGroup { - public long RoomInstanceId { get; set; } + /// String, not long — see the /instances comment: the + /// JS admin SPA mangles int64s above 2^53 unless they're + /// serialized as strings. + public string RoomInstanceId { get; set; } = ""; public long RoomId { get; set; } public long SubRoomId { get; set; } public string RoomName { get; set; } = ""; @@ -2948,7 +2959,11 @@ public class AdminController( public sealed record ForceJoinRequest( long RoomId, long? SubRoomId, - long? RoomInstanceId, + // String, not long?: the "send into this instance" flow echoes the + // roomInstanceId the admin SPA received (now a string, to survive + // JSON.parse of >2^53 ids). Parsed to long below; blank/omitted = + // mint a fresh default instance. + string? RoomInstanceId, string? PhotonRoomId, string? PhotonRegionId); @@ -2988,8 +3003,9 @@ public class AdminController( var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId) ? body.PhotonRoomId : $"^{room.Name.ToLowerInvariant()}_{room.Id}"; - var roomInstanceId = body.RoomInstanceId - ?? (room.Id * 100_000L + subRoomId); + var roomInstanceId = long.TryParse(body.RoomInstanceId, out var parsedInstanceId) + ? parsedInstanceId + : (room.Id * 100_000L + subRoomId); var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId) ? body.PhotonRegionId : "us"; @@ -3036,7 +3052,7 @@ public class AdminController( id, roomId = body.RoomId, subRoomId, - roomInstanceId, + roomInstanceId = roomInstanceId.ToString(), photonRoomId, }); } diff --git a/DorkNet.Server/admin-ui/src/lib/types.ts b/DorkNet.Server/admin-ui/src/lib/types.ts index 2c13005..f61adc8 100644 --- a/DorkNet.Server/admin-ui/src/lib/types.ts +++ b/DorkNet.Server/admin-ui/src/lib/types.ts @@ -113,7 +113,10 @@ export interface RoomRoleGrant { // else lowest-pid). The matching participant has isMaster=true so the // UI can tag a row without recomputing. export interface RoomInstance { - roomInstanceId: number; + // String, not number: instance ids are int64s past JS's 2^53 safe + // range, so the server sends them as strings to survive JSON.parse. + // Used verbatim in pull/close URLs — never coerce to Number. + roomInstanceId: string; roomId: number; subRoomId: number; roomName: string; @@ -229,7 +232,7 @@ export interface Stats { roomId: number; name: string; subRoomId: number; - roomInstanceId: number; + roomInstanceId: string; photonRoomId: string; photonRegionId: string; isPrivate: boolean; @@ -287,7 +290,9 @@ export interface StorefrontDefinition { } export interface Instance { - roomInstanceId: number; + // String, not number — int64 past JS's 2^53 safe range (server sends + // it as a string so JSON.parse doesn't round it). + roomInstanceId: string; roomId: number; subRoomId: number; roomName: string;