using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace DorkNet.Server.Tests; /// /// 2023 room roles, the co-owner invite flow and the sub-room role-permission /// matrix, against the client contract: /// /// Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30, /// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 — /// the client takes the local role from the Roles list and only lets a /// player edit roles strictly below their own, Creator excepted /// (OMJKHJLFOCO.DIIMLMKEJLC). /// Invite: owner PUT rooms/{id}/roles/{target}/invite role=30; /// invitee gets message Type 62 with RoomId; accepts with /// PUT rooms/{id}/roles/{me} role=30, rejects with /// PUT rooms/{id}/roles/{me}/invite role=0. /// Permissions: PUT rooms/{id}/subrooms/{sub}/permissions raw /// JSON array of {Permission, Role, Override, Type, Value}; read back from /// GET photon_access_token. /// /// public sealed class RoomRoles2023Tests : IClassFixture { private readonly DorkNetServerFactory _factory; public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory; // ── pure rules ────────────────────────────────────────────────────── private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator }; private static RoomRoleEntity Row(long player, int stored, bool accepted = true) => new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted }; [Fact] public void Creator_is_255_and_listed_once() { var room = Room(creator: 7); var wires = RoomRoleRules.BuildWires(room, new[] { Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator Row(8, RoomRoleRules.StoredModerator), Row(9, RoomRoleRules.StoredCoOwner, accepted: false), }); Assert.Single(wires, w => w.AccountId == 7); Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role); Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role); var invited = wires.Single(w => w.AccountId == 9); Assert.Equal(0, invited.Role); Assert.Equal(30, invited.InvitedRole); } [Fact] public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions() { var room = Room(creator: 7); var rows = Array.Empty(); foreach (var role in new[] { 0, 10, 20, 30, 31, 255 }) Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}"); } [Fact] public void Co_owner_may_edit_only_roles_below_their_own() { var room = Room(creator: 7); var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) }; Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20)); Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0)); Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30)); Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31)); Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255)); // A moderator edits nothing. Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0)); // Nor does a stranger. Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0)); } [Fact] public void Role_changes_follow_the_ladder() { var room = Room(creator: 7); var rows = new List { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator), Row(10, RoomRoleRules.StoredCoOwner, accepted: false), }; // Creator: anything on anyone but themselves. Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k)); Assert.Equal(RoomRoleRules.ChangeKind.Clear, k); Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k)); Assert.Equal(RoomRoleRules.ChangeKind.Invite, k); Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _)); Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _)); // Co-owner: only below 30, on targets below 30. Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _)); Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _)); Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _)); Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner // …and cannot remove a peer co-owner. Assert.Equal("forbidden", RoomRoleRules.CheckChange( room, new List { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _)); // Moderator and strangers manage nobody. Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _)); Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _)); // Self-service: accept exactly the invited role, decline, leave. Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k)); Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k); Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k)); Assert.Equal(RoomRoleRules.ChangeKind.Clear, k); Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _)); Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _)); Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _)); } [Fact] public void Permission_matrix_merges_stored_cells_over_defaults() { var merged = RoomPermissionMatrix.Merge(new[] { new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" }, new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" }, }); Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value); Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value); Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value); Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count); } [Fact] public void Permission_body_accepts_numbers_strings_and_enum_names() { var cells = RoomPermissionMatrix.Parse( """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" + """{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" + """{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]"""); Assert.NotNull(cells); Assert.Equal(3, cells!.Count); Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]); Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]); Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]); Assert.Null(RoomPermissionMatrix.Parse("role=30")); } // ── over HTTP ─────────────────────────────────────────────────────── [Fact] public async Task Roles_list_reports_the_creator_as_Creator_255() { var (owner, _, _) = await PlayersAsync(); var roomId = await SeedRoomAsync(owner.PlayerId); using var rooms = Client("rooms", owner); var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles"); var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId); Assert.Equal(255, me.GetProperty("Role").GetInt32()); var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}"); var detailsRole = details.GetProperty("Roles").EnumerateArray() .Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId); Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32()); var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}"); Assert.Equal(255, single.GetProperty("Role").GetInt32()); } [Fact] public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee() { var (owner, invitee, _) = await PlayersAsync(); var roomId = await SeedRoomAsync(owner.PlayerId); using var ownerRooms = Client("rooms", owner); var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30")); var inviteeRow = RoleOf(invited, invitee.PlayerId); Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32()); Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32()); // The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId. long messageId; await using (var scope = _factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var msg = await db.Messages.SingleAsync(m => m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId && m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited); Assert.Equal(owner.PlayerId, msg.SenderPlayerId); Assert.Null(msg.ReadAt); messageId = msg.Id; } // Accept: the invitee PUTs their own role to 30. using var inviteeRooms = Client("rooms", invitee); var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30")); var acceptedRow = RoleOf(accepted, invitee.PlayerId); Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32()); Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32()); await using (var scope = _factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt); Assert.True(await db.RoomRoles.AnyAsync(r => r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner)); } } [Fact] public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote() { var (owner, invitee, stranger) = await PlayersAsync(); var roomId = await SeedRoomAsync(owner.PlayerId); using var ownerRooms = Client("rooms", owner); await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30")); using var inviteeRooms = Client("rooms", invitee); var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0")); Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(), e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId); // With the invite gone, the same accept is refused. using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30")); Assert.Equal(HttpStatusCode.Forbidden, again.StatusCode); // A stranger can neither promote themselves nor invite anyone. using var strangerRooms = Client("rooms", stranger); using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20")); Assert.Equal(HttpStatusCode.Forbidden, self.StatusCode); using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30")); Assert.Equal(HttpStatusCode.Forbidden, other.StatusCode); } [Fact] public async Task Co_owner_manages_roles_below_but_not_co_owners() { var (owner, coOwner, target) = await PlayersAsync(); var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner)); using var coRooms = Client("rooms", coOwner); var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20")); Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32()); using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30")); Assert.Equal(HttpStatusCode.Forbidden, inviteCo.StatusCode); // The creator can directly add and remove a co-owner. using var ownerRooms = Client("rooms", owner); var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30")); Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32()); // …and now the co-owner can't touch their peer. using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0")); Assert.Equal(HttpStatusCode.Forbidden, demote.StatusCode); var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0")); Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(), e => e.GetProperty("AccountId").GetInt64() == target.PlayerId); await using var scope = _factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId && m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded)); Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId && m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved)); } [Fact] public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back() { var (owner, coOwner, _) = await PlayersAsync(); var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner)); using var ownerRooms = Client("rooms", owner); var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]"""; using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body)) { var text = await put.Content.ReadAsStringAsync(); Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}"); using var json = JsonDocument.Parse(text); Assert.Equal(roomId, json.RootElement.GetProperty("RoomId").GetInt64()); // full room details } // The owner enters the room, then the matrix comes back with the token. using var match = Client("match", owner); using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary()))) Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync()); var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token"); var cells = token.GetProperty("Permissions").EnumerateArray().ToList(); Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30 && c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString()); Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255 && c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString()); Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255 && c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString()); // A co-owner may not edit the Co-Owner row, but may edit Moderator. using var coRooms = Client("rooms", coOwner); using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]""")) Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode); using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", """[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]""")) Assert.True(allowed.IsSuccessStatusCode, await allowed.Content.ReadAsStringAsync()); await using var scope = _factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync(); Assert.Equal(3, stored.Count); Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0 Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing } [Fact] public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token() { var hub = new CapturingHubContext(); var factory = _factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => { Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions .RemoveAll>(services); services.AddSingleton>(hub); })); HttpClient FactoryClient(string sub, GameClientSession? session = null) { var c = factory.CreateClient(new() { AllowAutoRedirect = false }); c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}"); c.DefaultRequestHeaders.Accept.ParseAdd("application/json"); c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); if (session is not null) c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); return c; } using var setup = FactoryClient("api"); var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); var roomId = await SeedRoomAsync(owner.PlayerId); using var ownerRooms = FactoryClient("rooms", owner); await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30")); var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2")); Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32()); Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64()); Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32()); var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate")); Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32()); using var match = FactoryClient("match", owner); using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary()))) Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync()); using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", """[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]""")) Assert.True(put.IsSuccessStatusCode, await put.Content.ReadAsStringAsync()); var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken")); Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0); Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(), c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21"); } // ── helpers ───────────────────────────────────────────────────────── private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync() { using var setup = Client("api"); return ( await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain), await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain), await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain)); } private async Task SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles) { var roomId = 9_300_000 + Random.Shared.Next(1, 99_999); await using var scope = _factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); db.Rooms.Add(new RoomEntity { Id = roomId, Name = $"Roles{Guid.NewGuid():N}"[..18], CreatorPlayerId = creatorId, ImageName = RoomService.DefaultRoomImageName, Accessibility = 0, LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92", }); db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" }); foreach (var (pid, role) in roles) db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true }); await db.SaveChangesAsync(); return roomId; } private static JsonElement RoleOf(JsonElement details, long playerId) => details.GetProperty("Roles").EnumerateArray() .Single(e => e.GetProperty("AccountId").GetInt64() == playerId); private static Task PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields) => client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair(f.Key, f.Value)))); private static async Task PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields) { using var response = await PutFormRawAsync(client, path, fields); var body = await response.Content.ReadAsStringAsync(); Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}"); return JsonDocument.Parse(body).RootElement.Clone(); } private static Task PutJsonRawAsync(HttpClient client, string path, string json) => client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json")); private static async Task GetJsonAsync(HttpClient client, string path) { using var response = await client.GetAsync(path); var body = await response.Content.ReadAsStringAsync(); Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}"); return JsonDocument.Parse(body).RootElement.Clone(); } private HttpClient Client(string subdomain, GameClientSession? session = null) { var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}"); client.Timeout = TimeSpan.FromSeconds(30); client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); if (session is not null) client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); return client; } }