using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.Admin; using RadicadeServer.API.Avatars.V2; using RadicadeServer.DB; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// Who a room's join box is from. The box a player receives on joining already travelled with /// RecNet's Gift.FromPlayerId - it is the field the client reads to put a name on the box - /// but a room's join gift left it at zero, so every join box arrived from nobody. /// /// The room's Room Details modal now carries a FromPlayerId, which lands in /// Rooms.JoinGiftFromPlayerId and from there on the box. The value crosses a JSON boundary /// twice, and an account id is eighteen digits, so the panel and the route keep it as digits in a /// string rather than as a number: a JSON number is read back as a double and its last digits are /// rounded away, which would name a different account. /// /// These tests drive the real admin routes and then read the box the way a client does - /// GET api/avatar/v2/gifts - rather than inspecting the row the route answered with. [Collection(DbFactoryCollection.Name)] public class RoomJoinGiftFromTests { private const string Secret = "room-join-gift-from-key"; private const long RoomId = 247; private const long Joiner = 114903603133502227; private const long Sender = 733901112234567890; private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); /// A room that hands every player a box on join, which is what makes the sender matter. private static async Task RoomWithJoinGiftAsync() { var db = NewDb(); db.Rooms.Add(new RoomData { RoomId = RoomId, Name = "RecCenter", CreatorAccountId = 1, JoinGiftEnabled = true, JoinGiftOncePerPlayer = true, JoinGiftTitle = "Room Visit Gift", JoinGiftMessage = "Thanks for visiting!", JoinGiftCurrencyType = RadicadeServer.RecNet.Enums.Store.CurrencyType.RecCenterTokens, JoinGiftCurrencyAmount = 25 }); await db.SaveChangesAsync(); return db; } /// Patches one room the way the panel does, and returns the answer's status. private static async Task SaveRoomAsync(RecDBContext db, string payloadJson) { var api = new AdminPanelAPI(db); var originalKey = DBConfig.AdminApiKey; DBConfig.AdminApiKey = Secret; try { var payload = JsonDocument.Parse(payloadJson).RootElement; return await api.Update("rooms", RoomId, Secret, payload); } finally { DBConfig.AdminApiKey = originalKey; } } /// The box as the recipient's client reads it: GET api/avatar/v2/gifts. private static async Task> ClientGiftsAsync(RecDBContext db, long playerId) { var api = new AvatarV2(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, playerId.ToString())], "test")) } } }; var result = await api.GetGifts(); var ok = Assert.IsType(result); return Assert.IsAssignableFrom>(ok.Value).ToList(); } /// The one box the joiner's client reads, with the server's own naming on the wire. private static async Task JoinerBoxOnTheWireAsync(RecDBContext db) { Assert.True(await RoomJoinGiftUtils.TryDeliverAsync(db, Joiner, RoomId)); var read = Assert.Single(await ClientGiftsAsync(db, Joiner)); return JsonDocument.Parse(JsonSerializer.Serialize(read)).RootElement; } [Fact] public async Task TheSenderThePanelSavesIsTheSenderOnTheJoinBox() { var db = await RoomWithJoinGiftAsync(); var result = await SaveRoomAsync(db, $$""" { "join_gift_from_player_id": "{{Sender}}", "join_gift_title": "Room Visit Gift" } """); Assert.IsType(result); var stored = await db.Rooms.SingleAsync(x => x.RoomId == RoomId); Assert.Equal(Sender, stored.JoinGiftFromPlayerId); // And what the joiner's client reads off the box it was handed. var wire = await JoinerBoxOnTheWireAsync(db); Assert.Equal(Sender, wire.GetProperty("FromPlayerId").GetInt64()); } [Fact] public async Task ASeventeenDigitSenderIsNotRoundedOnItsWayThroughThePanel() { var db = await RoomWithJoinGiftAsync(); Assert.True(Sender > int.MaxValue); await SaveRoomAsync(db, $$""" { "join_gift_from_player_id": "{{Sender}}" } """); // The panel reads the id back as digits in a string, so the modal shows the digits that // were saved rather than a rounded double, and saving again cannot drift the account. var api = new AdminPanelAPI(db); var originalKey = DBConfig.AdminApiKey; DBConfig.AdminApiKey = Secret; string? asPanelReadsIt; try { var details = Assert.IsType(await api.RoomPanelDetails(RoomId, Secret)); using var doc = JsonDocument.Parse(JsonSerializer.Serialize(details.Value)); var field = doc.RootElement.GetProperty("room").GetProperty("join_gift_from_player_id"); Assert.Equal(JsonValueKind.String, field.ValueKind); asPanelReadsIt = field.GetString(); } finally { DBConfig.AdminApiKey = originalKey; } Assert.Equal(Sender.ToString(), asPanelReadsIt); } [Fact] public async Task AChangedSenderReissuesTheBox() { var db = await RoomWithJoinGiftAsync(); var before = (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftVersion; await SaveRoomAsync(db, $$""" { "join_gift_from_player_id": "{{Sender}}" } """); // A new version means a new gift drop id, so a player who already opened the old box is // handed the one that now names a sender. var after = await db.Rooms.SingleAsync(x => x.RoomId == RoomId); Assert.True(after.JoinGiftVersion > before); } [Fact] public async Task ASaveThatLeavesTheSenderAloneDoesNotReissueTheBox() { var db = await RoomWithJoinGiftAsync(); await SaveRoomAsync(db, $$""" { "join_gift_from_player_id": "{{Sender}}" } """); var afterFirstSave = (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftVersion; // The same id again - the modal posts every field on every save. await SaveRoomAsync(db, $$""" { "join_gift_from_player_id": "{{Sender}}", "join_gift_enabled": true, "join_gift_once_per_player": true } """); var again = await db.Rooms.SingleAsync(x => x.RoomId == RoomId); Assert.Equal(afterFirstSave, again.JoinGiftVersion); Assert.Equal(Sender, again.JoinGiftFromPlayerId); } [Fact] public async Task AClearedSenderLeavesTheBoxWithNoSender() { var db = await RoomWithJoinGiftAsync(); await SaveRoomAsync(db, $$""" { "join_gift_from_player_id": "{{Sender}}" } """); // The blank field is what the modal sends for no sender, and it has to clear the room // rather than leave the old account on the box. Assert.IsType(await SaveRoomAsync(db, """{ "join_gift_from_player_id": "" }""")); Assert.Equal(0, (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftFromPlayerId); var wire = await JoinerBoxOnTheWireAsync(db); Assert.Equal(0, wire.GetProperty("FromPlayerId").GetInt64()); } [Fact] public async Task ARoomNoPanelFieldArrivedForKeepsItsSender() { var db = await RoomWithJoinGiftAsync(); await SaveRoomAsync(db, $$""" { "join_gift_from_player_id": "{{Sender}}" } """); // An older panel, an import or a partial save leaves the field out entirely; that is not // a request to clear it. Assert.IsType(await SaveRoomAsync(db, """{ "min_level": 5 }""")); Assert.Equal(Sender, (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftFromPlayerId); } [Fact] public async Task ASenderThatIsNotAnAccountIdIsRefusedRatherThanKept() { var db = await RoomWithJoinGiftAsync(); await SaveRoomAsync(db, $$""" { "join_gift_from_player_id": "{{Sender}}" } """); // The panel refuses this before it posts; if it ever reaches the route anyway, the room // must not be left believing the sender changed. var result = await SaveRoomAsync(db, """{ "join_gift_from_player_id": "not-a-player" }"""); var bad = Assert.IsType(result); Assert.Contains("join_gift_from_player_id", bad.Value?.ToString()); Assert.Equal(Sender, (await db.Rooms.SingleAsync(x => x.RoomId == RoomId)).JoinGiftFromPlayerId); } [Fact] public async Task ARoomThatNamesNoSenderStillHandsOutItsBox() { var db = await RoomWithJoinGiftAsync(); // Nothing here sets a sender: the box is still delivered, it just names nobody, which is // how every join box behaved before the room could name a sender. var wire = await JoinerBoxOnTheWireAsync(db); Assert.Equal(0, wire.GetProperty("FromPlayerId").GetInt64()); Assert.Equal(25, wire.GetProperty("Currency").GetInt32()); } }