using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Rooms; using RadicadeServer.RoomServer; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// The Moderator Permission Settings screen writes a subroom's overrides, and /// rooms/photon_access_token is the only thing that ever reads them back to the client. /// Both halves failed before: the PUT was a stub that stored nothing, and the token answered an /// empty table, so every box in that screen came up unchecked no matter what a creator had /// saved. These pin the pair - that a stored override survives the round trip, that unchecking /// the box removes it, that (Permission, Role) is the identity so re-sending overwrites, and /// that the token table the client actually applies carries the override over the default. /// [Collection(DbFactoryCollection.Name)] public class SubRoomPermissionTests { private const long CreatorId = 7; private const long StrangerId = 99; private const long RoomId = 1; private const long SubRoomId = 40; private static readonly JsonSerializerOptions ServerJson = new() { PropertyNamingPolicy = null, WriteIndented = true, }; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options; public TestDbContextFactory() { options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; } public RecDBContext CreateDbContext() => new(options); } private static (TestDbContextFactory Factory, RecDBContext Db) CreateContext() { var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; return (factory, factory.CreateDbContext()); } private static void SetUser(Controller controller, long accountId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static void SetJsonBody(Controller controller, string json) { var request = controller.ControllerContext.HttpContext.Request; request.ContentType = "application/json"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); } private static RoomServerAPI Api(RecDBContext db, long accountId) { var api = new RoomServerAPI(db); SetUser(api, accountId); return api; } private static async Task SeedRoomAsync(RecDBContext db, long creatorId = CreatorId) { db.Rooms.Add(new RoomData { RoomId = RoomId, Name = "Rec Center", CreatorAccountId = creatorId, Accessibility = Accessibility.Public, MaxPlayers = 16, CreatedAt = DateTime.UtcNow, }); db.SubRooms.Add(new SubRoom { SubRoomId = SubRoomId, RoomId = RoomId, Name = "Rec Center", UnitySceneId = "RecCenterScene", MaxPlayers = 16, Accessibility = Accessibility.Public, DataBlob = "", }); await db.SaveChangesAsync(); } /// /// The permission entries a route answered with. The GET and the token table both hand back /// objects whose keys are the client's own field names, so they are read as JSON. /// private static List PermissionList(object? value) { using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value, ServerJson)); return doc.RootElement.EnumerateArray().Select(x => x.Clone()).ToList(); } private static List RootPropertyList(object? value, string property) { using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value, ServerJson)); return doc.RootElement.GetProperty(property).EnumerateArray().Select(x => x.Clone()).ToList(); } private static (string Permission, long Role) Pair(JsonElement entry) => (entry.GetProperty("Permission").GetString() ?? "", entry.GetProperty("Role").GetInt64()); private static async Task> StoredRowsAsync(TestDbContextFactory factory) { await using var db = factory.CreateDbContext(); return await db.SubRoomPermissions.AsNoTracking().OrderBy(x => x.Id).ToListAsync(); } /// /// A stored row is the override, so reads always answer Override: true; a pair with no /// row is simply absent. The GET route is what the settings screen loads its checkboxes from. /// [Fact] public async Task StoredOverridesComeBackAsTheyWereSaved() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var api = Api(db, CreatorId); SetJsonBody(api, """ [ { "Permission": "CAN_SPAWN_INVENTIONS", "Role": 0, "Override": true, "Type": 0, "Value": "False" }, { "Permission": "CAN_INVITE", "Role": 0, "Override": true, "Type": 0, "Value": "True" } ] """); Assert.IsType(await api.SetSubRoomPermissions(RoomId, SubRoomId)); var read = Api(db, CreatorId); var result = Assert.IsType(await read.GetSubRoomPermissions(RoomId, SubRoomId)); var entries = PermissionList(result.Value); Assert.Equal(2, entries.Count); Assert.Equal(("CAN_SPAWN_INVENTIONS", 0L), Pair(entries[0])); Assert.Equal("False", entries[0].GetProperty("Value").GetString()); Assert.Equal(("CAN_INVITE", 0L), Pair(entries[1])); Assert.Equal("True", entries[1].GetProperty("Value").GetString()); // A stored row IS an override, so the box beside it reads as checked. Assert.All(entries, x => Assert.True(x.GetProperty("Override").GetBoolean())); } /// /// Unchecking a box is the ABSENCE of a row, not a row that says false - otherwise the /// default table would be overridden by a value the creator just asked to stop overriding. /// [Fact] public async Task UncheckingTheOverrideBoxFallsBackToTheDefault() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var set = Api(db, CreatorId); SetJsonBody(set, """ [ { "Permission": "CAN_SAVE_INVENTIONS", "Role": 30, "Override": true, "Type": 0, "Value": "False" } ] """); Assert.IsType(await set.SetSubRoomPermissions(RoomId, SubRoomId)); Assert.Single(await StoredRowsAsync(factory)); var unset = Api(db, CreatorId); SetJsonBody(unset, """ [ { "Permission": "CAN_SAVE_INVENTIONS", "Role": 30, "Override": false, "Type": 0, "Value": "False" } ] """); Assert.IsType(await unset.SetSubRoomPermissions(RoomId, SubRoomId)); Assert.Empty(await StoredRowsAsync(factory)); var read = Api(db, CreatorId); var result = Assert.IsType(await read.GetSubRoomPermissions(RoomId, SubRoomId)); Assert.Empty(PermissionList(result.Value)); } /// /// The client re-sends the whole table when one checkbox changes, so a pair that arrives /// twice must update its row. Appending instead would leave two rows for one pair, and the /// token table would carry whichever the query happened to return last. /// [Fact] public async Task ResendingAPairReplacesItsRowInsteadOfAddingASecond() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var first = Api(db, CreatorId); SetJsonBody(first, """ [ { "Permission": "CAN_USE_DELETE_ALL_BUTTON", "Role": 0, "Override": true, "Type": 0, "Value": "False" } ] """); await first.SetSubRoomPermissions(RoomId, SubRoomId); var second = Api(db, CreatorId); SetJsonBody(second, """ [ { "Permission": "CAN_USE_DELETE_ALL_BUTTON", "Role": 0, "Override": true, "Type": 3, "Value": "True" } ] """); await second.SetSubRoomPermissions(RoomId, SubRoomId); var rows = await StoredRowsAsync(factory); var row = Assert.Single(rows); Assert.Equal("CAN_USE_DELETE_ALL_BUTTON", row.permission); Assert.Equal(3, row.type); Assert.Equal("True", row.value); } /// Deciding what a role may do is a co-owner decision. [Fact] public async Task OnlyACoOwnerCanChangeThePermissions() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var stranger = Api(db, StrangerId); SetJsonBody(stranger, """ [ { "Permission": "CAN_USE_MAKER_PEN", "Role": 0, "Override": true, "Type": 0, "Value": "True" } ] """); Assert.IsType(await stranger.SetSubRoomPermissions(RoomId, SubRoomId)); Assert.Empty(await StoredRowsAsync(factory)); } [Fact] public async Task AnUnknownSubroomIsNotFound() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var api = Api(db, CreatorId); SetJsonBody(api, "[]"); Assert.IsType(await api.SetSubRoomPermissions(RoomId, SubRoomId + 1)); Assert.IsType(await api.GetSubRoomPermissions(RoomId, SubRoomId + 1)); } /// /// The whole point of storing them: the table the client applies when it spawns has to carry /// the subroom's override over the default for that pair, and a pair the defaults do not /// carry has to be added - otherwise a creator's checkbox is saved and then ignored. /// [Fact] public async Task TheTokenTableCarriesTheSubroomsOverrides() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); const long instanceId = 900; db.RoomInstances.Add(new RoomInstance { roomInstanceId = instanceId, roomId = RoomId, subRoomId = SubRoomId, maxCapacity = 16, }); db.Presences.Add(new RealPresence { playerId = CreatorId, isOnline = true, roomInstance = instanceId, }); await db.SaveChangesAsync(); var set = Api(db, CreatorId); SetJsonBody(set, """ [ { "Permission": "CAN_SPAWN_INVENTIONS", "Role": 0, "Override": true, "Type": 0, "Value": "False" }, { "Permission": "CAN_INVITE", "Role": 0, "Override": true, "Type": 0, "Value": "True" } ] """); await set.SetSubRoomPermissions(RoomId, SubRoomId); var token = Api(db, CreatorId); var result = Assert.IsType(await token.GetPhotonAccessToken()); var table = RootPropertyList(result.Value, "Permissions"); // The default table includes CAN_INVITE (permission id 4), which the keepsake // authorization gate also reads; both overrides replace defaults in place. Assert.Equal(12, table.Count); using (var doc = JsonDocument.Parse(JsonSerializer.Serialize(result.Value, ServerJson))) Assert.Equal(instanceId, doc.RootElement.GetProperty("RoomInstanceId").GetInt64()); var spawn = Assert.Single(table.Where(x => x.GetProperty("Permission").GetString() == "CAN_SPAWN_INVENTIONS" && x.GetProperty("Role").GetInt64() == 0)); Assert.Equal("False", spawn.GetProperty("Value").GetString()); Assert.True(spawn.GetProperty("Override").GetBoolean()); // Replaced where it stood rather than appended, so the order cannot shift under the client // and no pair is listed twice with two different values. Assert.Equal(("CAN_SPAWN_INVENTIONS", 0L), Pair(table[3])); Assert.Equal(("CAN_INVITE", 0L), Pair(table[5])); Assert.Equal("True", table[5].GetProperty("Value").GetString()); // The default that ships as Override:false is the client's "this is not an override", and a // subroom override must not have disturbed it. It follows the new CAN_INVITE row. Assert.Equal(("CAN_USE_MAKER_PEN", 30L), Pair(table[6])); Assert.False(table[6].GetProperty("Override").GetBoolean()); } /// /// Every spelling of the same payload has to land on the same stored entry, because the /// table that matters is keyed by the permission's NAME. A body carrying the enum's number, /// or the client's own `PermissionType` field, or the role tier as a name, would otherwise /// save an entry that matches no default: the checkbox would look saved and change nothing. /// [Theory] [InlineData(""" [ { "Permission": 20, "Role": 0, "Override": true, "Type": 0, "Value": "False" } ] """, "CAN_SPAWN_INVENTIONS", 0L)] [InlineData(""" [ { "PermissionType": "CAN_SPAWN_INVENTIONS", "Role": 0, "Override": true, "Type": 0, "Value": "False" } ] """, "CAN_SPAWN_INVENTIONS", 0L)] [InlineData(""" [ { "PermissionTypeOld": "CAN_SPAWN_INVENTIONS", "Role": "0", "Override": true, "Type": 0, "Value": "False" } ] """, "CAN_SPAWN_INVENTIONS", 0L)] [InlineData(""" [ { "Permission": "CAN_SPAWN_INVENTIONS", "Role": "CoOwner", "Override": true, "Type": 0, "Value": "False" } ] """, "CAN_SPAWN_INVENTIONS", 30L)] public async Task ASpellingOfTheSameEntryStoresTheSameKey(string body, string permission, long role) { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var api = Api(db, CreatorId); SetJsonBody(api, body); Assert.IsType(await api.SetSubRoomPermissions(RoomId, SubRoomId)); var row = Assert.Single(await StoredRowsAsync(factory)); Assert.Equal(permission, row.permission); Assert.Equal(role, row.role); Assert.Equal("False", row.value); } /// /// A subroom override is the creator's decision for that room and wins over the developer /// grant, so nobody can build their way around it by being staff. /// [Fact] public async Task ASubRoomOverrideBeatsTheDeveloperGrant() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); const long instanceId = 901; db.Profiles.Add(new Profile { accountId = CreatorId, username = "dev", displayName = "dev", role = "developer", profileImage = DBConfig.DefaultProfileImage, }); db.RoomInstances.Add(new RoomInstance { roomInstanceId = instanceId, roomId = RoomId, subRoomId = SubRoomId, }); db.Presences.Add(new RealPresence { playerId = CreatorId, isOnline = true, roomInstance = instanceId, }); await db.SaveChangesAsync(); var plain = Api(db, CreatorId); var beforeResult = Assert.IsType(await plain.GetPhotonAccessToken()); var before = RootPropertyList(beforeResult.Value, "Permissions"); Assert.Equal(("CAN_USE_MAKER_PEN", 0L), Pair(before[0])); Assert.True(before[0].GetProperty("Override").GetBoolean()); Assert.Equal(13, before.Count); var set = Api(db, CreatorId); SetJsonBody(set, """ [ { "Permission": "CAN_USE_MAKER_PEN", "Role": 0, "Override": true, "Type": 0, "Value": "False" } ] """); await set.SetSubRoomPermissions(RoomId, SubRoomId); var after = Api(db, CreatorId); var afterResult = Assert.IsType(await after.GetPhotonAccessToken()); var table = RootPropertyList(afterResult.Value, "Permissions"); Assert.Equal("False", Assert.Single(table.Where(x => x.GetProperty("Permission").GetString() == "CAN_USE_MAKER_PEN" && x.GetProperty("Role").GetInt64() == 0)) .GetProperty("Value").GetString()); Assert.Equal(13, table.Count); } /// /// A player standing in no instance still gets a usable table: the default, without the /// subroom merge. An empty one is what left every permission unchecked before. /// [Fact] public async Task StandingNowhereStillAnswersTheDefaultTable() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var api = Api(db, CreatorId); var result = Assert.IsType(await api.GetPhotonAccessToken()); var table = RootPropertyList(result.Value, "Permissions"); Assert.Equal(12, table.Count); var invite = Assert.Single(table.Where(x => x.GetProperty("Permission").GetString() == "CAN_INVITE" && x.GetProperty("Role").GetInt64() == 0)); Assert.True(invite.GetProperty("Override").GetBoolean()); Assert.Equal("True", invite.GetProperty("Value").GetString()); } }