mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat: Update RoomInstance handling to serialize roomInstanceId as string for JSON compatibility; add tests to ensure correct behavior
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>instance_not_active</c>. The fix serializes the
|
||||||
|
/// id as a STRING so the exact digits survive the browser round-trip.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminInstancePullTests : IClassFixture<DorkNetServerFactory>
|
||||||
|
{
|
||||||
|
// 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<DorkNetDbContext>();
|
||||||
|
|
||||||
|
// 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<PlayerPresenceService>();
|
||||||
|
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<PlayerPresenceService>();
|
||||||
|
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<DorkNetDbContext>();
|
||||||
|
(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<PlayerPresenceService>();
|
||||||
|
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<JsonElement> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1285,7 +1285,13 @@ public class AdminController(
|
|||||||
master = i.pids.OrderBy(p => p).FirstOrDefault();
|
master = i.pids.OrderBy(p => p).FirstOrDefault();
|
||||||
return new
|
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,
|
roomId = i.roomId,
|
||||||
subRoomId = i.subRoomId,
|
subRoomId = i.subRoomId,
|
||||||
roomName = i.roomName,
|
roomName = i.roomName,
|
||||||
@@ -1341,7 +1347,7 @@ public class AdminController(
|
|||||||
sample);
|
sample);
|
||||||
await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}");
|
await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { ok = true, instanceId, roomInstanceId = sample.RoomInstanceId });
|
return Ok(new { ok = true, instanceId = instanceId.ToString(), roomInstanceId = sample.RoomInstanceId.ToString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/close</c>
|
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/close</c>
|
||||||
@@ -2042,7 +2048,9 @@ public class AdminController(
|
|||||||
room.RoomId,
|
room.RoomId,
|
||||||
Name = roomName ?? room.Name,
|
Name = roomName ?? room.Name,
|
||||||
room.SubRoomId,
|
room.SubRoomId,
|
||||||
room.RoomInstanceId,
|
// String — int64 past JS's 2^53 safe range (see the
|
||||||
|
// /instances comment).
|
||||||
|
RoomInstanceId = room.RoomInstanceId.ToString(),
|
||||||
room.PhotonRoomId,
|
room.PhotonRoomId,
|
||||||
room.PhotonRegionId,
|
room.PhotonRegionId,
|
||||||
room.IsPrivate,
|
room.IsPrivate,
|
||||||
@@ -2887,7 +2895,7 @@ public class AdminController(
|
|||||||
{
|
{
|
||||||
g = new InstanceGroup
|
g = new InstanceGroup
|
||||||
{
|
{
|
||||||
RoomInstanceId = room.RoomInstanceId,
|
RoomInstanceId = room.RoomInstanceId.ToString(),
|
||||||
RoomId = room.RoomId,
|
RoomId = room.RoomId,
|
||||||
SubRoomId = room.SubRoomId,
|
SubRoomId = room.SubRoomId,
|
||||||
RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"),
|
RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"),
|
||||||
@@ -2916,7 +2924,10 @@ public class AdminController(
|
|||||||
|
|
||||||
private sealed class InstanceGroup
|
private sealed class InstanceGroup
|
||||||
{
|
{
|
||||||
public long RoomInstanceId { get; set; }
|
/// <summary>String, not long — see the /instances comment: the
|
||||||
|
/// JS admin SPA mangles int64s above 2^53 unless they're
|
||||||
|
/// serialized as strings.</summary>
|
||||||
|
public string RoomInstanceId { get; set; } = "";
|
||||||
public long RoomId { get; set; }
|
public long RoomId { get; set; }
|
||||||
public long SubRoomId { get; set; }
|
public long SubRoomId { get; set; }
|
||||||
public string RoomName { get; set; } = "";
|
public string RoomName { get; set; } = "";
|
||||||
@@ -2948,7 +2959,11 @@ public class AdminController(
|
|||||||
public sealed record ForceJoinRequest(
|
public sealed record ForceJoinRequest(
|
||||||
long RoomId,
|
long RoomId,
|
||||||
long? SubRoomId,
|
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? PhotonRoomId,
|
||||||
string? PhotonRegionId);
|
string? PhotonRegionId);
|
||||||
|
|
||||||
@@ -2988,8 +3003,9 @@ public class AdminController(
|
|||||||
var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId)
|
var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId)
|
||||||
? body.PhotonRoomId
|
? body.PhotonRoomId
|
||||||
: $"^{room.Name.ToLowerInvariant()}_{room.Id}";
|
: $"^{room.Name.ToLowerInvariant()}_{room.Id}";
|
||||||
var roomInstanceId = body.RoomInstanceId
|
var roomInstanceId = long.TryParse(body.RoomInstanceId, out var parsedInstanceId)
|
||||||
?? (room.Id * 100_000L + subRoomId);
|
? parsedInstanceId
|
||||||
|
: (room.Id * 100_000L + subRoomId);
|
||||||
var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId)
|
var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId)
|
||||||
? body.PhotonRegionId
|
? body.PhotonRegionId
|
||||||
: "us";
|
: "us";
|
||||||
@@ -3036,7 +3052,7 @@ public class AdminController(
|
|||||||
id,
|
id,
|
||||||
roomId = body.RoomId,
|
roomId = body.RoomId,
|
||||||
subRoomId,
|
subRoomId,
|
||||||
roomInstanceId,
|
roomInstanceId = roomInstanceId.ToString(),
|
||||||
photonRoomId,
|
photonRoomId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,10 @@ export interface RoomRoleGrant {
|
|||||||
// else lowest-pid). The matching participant has isMaster=true so the
|
// else lowest-pid). The matching participant has isMaster=true so the
|
||||||
// UI can tag a row without recomputing.
|
// UI can tag a row without recomputing.
|
||||||
export interface RoomInstance {
|
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;
|
roomId: number;
|
||||||
subRoomId: number;
|
subRoomId: number;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
@@ -229,7 +232,7 @@ export interface Stats {
|
|||||||
roomId: number;
|
roomId: number;
|
||||||
name: string;
|
name: string;
|
||||||
subRoomId: number;
|
subRoomId: number;
|
||||||
roomInstanceId: number;
|
roomInstanceId: string;
|
||||||
photonRoomId: string;
|
photonRoomId: string;
|
||||||
photonRegionId: string;
|
photonRegionId: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
@@ -287,7 +290,9 @@ export interface StorefrontDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Instance {
|
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;
|
roomId: number;
|
||||||
subRoomId: number;
|
subRoomId: number;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user