using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using RadicadeServer; using RadicadeServer.DB; 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; /// /// A player's room role is what decides everything they may do in that room, and the client /// reads it out of the room payload. These pin the ways that went wrong: the role list being /// served empty (which silently removed every co-owner's and moderator's permissions), the /// owner missing from it, an invite matching the target's role in a DIFFERENT room, and the /// Creator tier being grantable through the role route. /// [Collection(DbFactoryCollection.Name)] public class RoomRolesTests { private const long CreatorId = 11; private const long RoomId = 5; private const long MemberId = 22; private const long StrangerId = 999; private static readonly JsonSerializerOptions ServerJson = new() { PropertyNamingPolicy = null, WriteIndented = true, }; private static string Serialize(object? value) => JsonSerializer.Serialize(value, ServerJson); private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options; public TestDbContextFactory() { // A unique database name isolates each test; the store is NOT given its own // InMemoryDatabaseRoot, because a distinct root makes Entity Framework build a // distinct internal service provider every time and it throws once the process // has more than twenty of them. 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 SetFormBody(Controller controller, string body) { var request = controller.ControllerContext.HttpContext.Request; request.ContentType = "application/x-www-form-urlencoded"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); } private static void SetQuery(Controller controller, string query) => controller.ControllerContext.HttpContext.Request.QueryString = new QueryString(query); private static async Task SeedRoomAsync(RecDBContext db, long roomId = RoomId, long creatorId = CreatorId) { var creator = await db.Players.FirstOrDefaultAsync(x => x.AccountId == creatorId); if (creator == null) db.Players.Add(new PlayerData { AccountId = creatorId, Hashword = "x" }); var member = await db.Players.FirstOrDefaultAsync(x => x.AccountId == MemberId); if (member == null) db.Players.Add(new PlayerData { AccountId = MemberId, Hashword = "x" }); db.Rooms.Add(new RoomData { RoomId = roomId, Name = $"Room {roomId}", CreatorAccountId = creatorId, Accessibility = Accessibility.Public, MaxPlayers = 8, CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } private static async Task ReadPersistedRoleAsync(TestDbContextFactory factory, long roomId, long accountId) { await using var db = factory.CreateDbContext(); return await db.RoomRoles.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId && x.AccountId == accountId); } [Fact] public async Task RolesAreServedEvenWhenTheHighRanksConfigIsMissing() { var (_, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); db.RoomRoles.Add(new RoomRole { RoomId = RoomId, AccountId = MemberId, Role = RoleType.CoOwner, InvitedRole = RoleType.None }); await db.SaveChangesAsync(); // No ServerConfig "HighRanks" row: the role list used to come back empty here, which // took every co-owner's and moderator's permissions with it. var roles = await RoomUtils.GetRoles(RoomId); Assert.Contains(roles, x => x.AccountId == MemberId && x.Role == RoleType.CoOwner); Assert.True(await MatchmakingUtils.IsAtLeastRole(MemberId, RoomId, RoleType.CoOwner)); } [Fact] public async Task TheRoomsCreatorAlwaysAppearsAsCreator() { var (_, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var roles = await RoomUtils.GetRoles(RoomId); var row = Assert.Single(roles); Assert.Equal(CreatorId, row.AccountId); Assert.Equal(RoleType.Creator, row.Role); // ...and the room the client loads carries the same entry, so the owner's own watch // shows them as the owner. var room = await RoomUtils.GetRoom(RoomId); Assert.Contains(room!.Roles, x => x.AccountId == CreatorId && x.Role == RoleType.Creator); } [Fact] public async Task RoleLookupUsesTheHighestRoleAnAccountHolds() { var (_, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); db.RoomRoles.AddRange( new RoomRole { RoomId = RoomId, AccountId = MemberId, Role = RoleType.Moderator, InvitedRole = RoleType.None }, new RoomRole { RoomId = RoomId, AccountId = MemberId, Role = RoleType.Creator, InvitedRole = RoleType.None }); await db.SaveChangesAsync(); var role = await RoomUtils.GetRoleForPlayer(MemberId, RoomId); Assert.NotNull(role); Assert.Equal(RoleType.Creator, role!.Role); Assert.True(await MatchmakingUtils.IsAtLeastRole(MemberId, RoomId, RoleType.CoOwner)); } [Fact] public async Task InvitingAPlayerOnlyTouchesThisRoomsRole() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db, RoomId, CreatorId); await SeedRoomAsync(db, 6, CreatorId); db.RoomRoles.Add(new RoomRole { RoomId = 6, AccountId = MemberId, Role = RoleType.CoOwner, InvitedRole = RoleType.None }); await db.SaveChangesAsync(); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetFormBody(api, "role=30"); var result = await api.InvitePlayerToRole(RoomId, MemberId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); // The other room's role is untouched... var other = await ReadPersistedRoleAsync(factory, 6, MemberId); Assert.NotNull(other); Assert.Equal(RoleType.CoOwner, other!.Role); Assert.Null(other.LastChangedByAccountId); // ...and this room has its own pending invite. var invited = await ReadPersistedRoleAsync(factory, RoomId, MemberId); Assert.NotNull(invited); Assert.Equal(RoleType.None, invited!.Role); Assert.Equal(RoleType.CoOwner, invited.InvitedRole); Assert.Equal(CreatorId, invited.LastChangedByAccountId); } [Fact] public async Task SettingARoleRecordsWhoChangedItAndReturnsTheRoom() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetQuery(api, "?role=20"); var result = await api.SetPlayerRole(RoomId, MemberId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Equal(RoomId, doc.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64()); var granted = await ReadPersistedRoleAsync(factory, RoomId, MemberId); Assert.Equal(RoleType.Moderator, granted!.Role); Assert.Equal(RoleType.None, granted.InvitedRole); Assert.Equal(CreatorId, granted.LastChangedByAccountId); } [Fact] public async Task RoleEntriesCarryLastChangedByAccountIdOnTheWire() { var (_, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); db.RoomRoles.Add(new RoomRole { RoomId = RoomId, AccountId = MemberId, Role = RoleType.CoOwner, InvitedRole = RoleType.None, LastChangedByAccountId = CreatorId, }); await db.SaveChangesAsync(); var room = await RoomUtils.GetRoom(RoomId); using var doc = JsonDocument.Parse(Serialize(room!.Roles)); var entry = doc.RootElement.EnumerateArray() .Single(x => x.GetProperty("AccountId").GetInt64() == MemberId); Assert.Equal(CreatorId, entry.GetProperty("LastChangedByAccountId").GetInt64()); Assert.True(entry.TryGetProperty("InvitedRole", out _)); } [Fact] public async Task TheCreatorTierCannotBeGrantedThroughTheRoleRoute() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetFormBody(api, "role=255"); var result = await api.SetPlayerRole(RoomId, MemberId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Null(await ReadPersistedRoleAsync(factory, RoomId, MemberId)); } [Fact] public async Task ARequestWithNoRoleIsRejectedInsteadOfDemotingThePlayer() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); db.RoomRoles.Add(new RoomRole { RoomId = RoomId, AccountId = MemberId, Role = RoleType.CoOwner, InvitedRole = RoleType.None }); await db.SaveChangesAsync(); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetFormBody(api, ""); // no role field at all var result = await api.SetPlayerRole(RoomId, MemberId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Contains("valid role", doc.RootElement.GetProperty("error").GetString()!); // An unbound role must never read as None, which would strip the co-owner. var unchanged = await ReadPersistedRoleAsync(factory, RoomId, MemberId); Assert.Equal(RoleType.CoOwner, unchanged!.Role); } [Fact] public async Task DemotingAPlayerRemovesTheirRoleEntry() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); db.RoomRoles.Add(new RoomRole { RoomId = RoomId, AccountId = MemberId, Role = RoleType.CoOwner, InvitedRole = RoleType.None }); await db.SaveChangesAsync(); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetFormBody(api, "role=0"); var result = await api.SetPlayerRole(RoomId, MemberId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Null(await ReadPersistedRoleAsync(factory, RoomId, MemberId)); } [Fact] public async Task SettingARoleRequiresCoOwnerRights() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db, RoomId, StrangerId); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetFormBody(api, "role=30"); var result = await api.SetPlayerRole(RoomId, MemberId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); Assert.Null(await ReadPersistedRoleAsync(factory, RoomId, MemberId)); } [Fact] public async Task TheRoomsCreatorCannotBeGivenAnotherRole() { var (_, db) = CreateContext(); await using var _db = db; await SeedRoomAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetFormBody(api, "role=20"); var result = await api.SetPlayerRole(RoomId, CreatorId); using var doc = JsonDocument.Parse(Serialize(result)); Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); } }