using System.Reflection; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using RadicadeServer.API.Moderation; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Messages; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// The moderator warning endpoints (POST api/playerwarnings and /// POST api/playerwarnings/acknowledge). /// /// The 2023 client has the whole feature - the "Warn Player" action, a warn screen with an /// "Acknowledge warning" button, and both route literals in its RecNet layer - but this server /// mapped neither route, so every warn 404'd. A 404 there is silent: nothing logs, the button /// just does nothing, which is why it went unnoticed. /// /// The route assertions are the point of this file, for the same reason they are in /// RoleLookupTests: a correct handler behind a missing prefix is the failure mode this project /// keeps hitting. These two templates are absolute (they start with "/"), so ASP.NET ignores the /// controller's own api/PlayerReporting/ prefix - the client's literal is the whole path. /// [Collection(DbFactoryCollection.Name)] public class PlayerWarningTests { // Mirrors the server's MVC JSON options, including PropertyNameCaseInsensitive - which is // what makes System.Text.Json reject a type whose members differ only in case. Without it a // case-colliding response DTO serializes fine in a test and 500s in production. private static readonly JsonSerializerOptions ServerJson = new() { PropertyNamingPolicy = null, PropertyNameCaseInsensitive = true, WriteIndented = true, }; private static string Serialize(object? value) => JsonSerializer.Serialize(value, ServerJson); private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static PlayerReportingAPI NewApi(RecDBContext db, long? callerId = 6) { var identity = new ClaimsIdentity("TestAuth"); if (callerId != null) identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, callerId.Value.ToString())); return new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } } }; } /// Give the controller a request body the way the client sends it. private static void SetBody(Controller controller, string body, string contentType) { var request = controller.ControllerContext.HttpContext.Request; request.ContentType = contentType; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); } private static async Task WithProfileAsync(long accountId, string? roles) { var db = NewDb(); db.Profiles.Add(new Profile { accountId = accountId, role = roles, username = $"player{accountId}", displayName = $"Player {accountId}", profileImage = DBConfig.DefaultProfileImage }); await db.SaveChangesAsync(); return db; } // ----------------------------------------------------------------------------------- // Routes // ----------------------------------------------------------------------------------- private static string[] PostTemplates(Type controller, string methodName) => controller.GetMethod(methodName)!.GetCustomAttributes() .Select(a => a.Template) .Where(t => t != null) .Select(t => t!) .ToArray(); [Fact] public void TheWarningRoutesAreTheAbsolutePathsTheClientBuilds() { var create = PostTemplates(typeof(PlayerReportingAPI), nameof(PlayerReportingAPI.CreateWarning)); var acknowledge = PostTemplates(typeof(PlayerReportingAPI), nameof(PlayerReportingAPI.AcknowledgeWarning)); // The client's own literals, verbatim from its metadata string table. Assert.Contains("/api/playerwarnings", create); Assert.Contains("/api/playerwarnings/acknowledge", acknowledge); // Every one of them absolute: a template that started with "api/" would be concatenated // onto the controller's "api/PlayerReporting/" prefix and answer a path no client asks for. Assert.All(create.Concat(acknowledge), template => Assert.StartsWith("/", template)); } // ----------------------------------------------------------------------------------- // Issuing a warning // ----------------------------------------------------------------------------------- [Fact] public async Task AModeratorWarnsAPlayerAndTheModeratorComesFromTheToken() { using var db = await WithProfileAsync(6, "developer,moderator"); var api = NewApi(db, 6); SetBody(api, "WarnedPlayerId=205&ReportCategory=101&DisplayReason=Sexual gestures&ModeratorNote=dfg", "application/x-www-form-urlencoded"); var result = Assert.IsType(await api.CreateWarning()); using var json = JsonDocument.Parse(Serialize(result.Value)); Assert.True(json.RootElement.GetProperty("Success").GetBoolean()); var warning = await db.PlayerWarnings.SingleAsync(); Assert.Equal(6, warning.ModeratorPlayerId); // the caller, never a body field Assert.Equal(205, warning.WarnedPlayerId); Assert.Equal(ReportCategory.CoC_Sexual, warning.ReportCategory); Assert.Equal("Sexual gestures", warning.DisplayReason); Assert.Equal("dfg", warning.ModeratorNote); Assert.Null(warning.AcknowledgedAt); Assert.NotEqual(default, warning.CreatedAt); } [Fact] public async Task AbsentFieldsAreStoredRatherThanInvented() { using var db = await WithProfileAsync(6, "moderator"); var api = NewApi(db, 6); SetBody(api, "WarnedPlayerId=206", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); var warning = await db.PlayerWarnings.SingleAsync(); Assert.Equal(0, (int)warning.ReportCategory); Assert.Null(warning.DisplayReason); Assert.Null(warning.ModeratorNote); } [Fact] public async Task WarningsAreAppendOnly() { using var db = await WithProfileAsync(6, "moderator"); foreach (var note in new[] { "first", "second" }) { var api = NewApi(db, 6); SetBody(api, $"WarnedPlayerId=207&ModeratorNote={note}", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); } var rows = await db.PlayerWarnings.Where(x => x.WarnedPlayerId == 207).OrderBy(x => x.Id).ToListAsync(); Assert.Equal(2, rows.Count); Assert.Equal(new[] { "first", "second" }, rows.Select(r => r.ModeratorNote)); } [Fact] public async Task TheJSONBodyShapeBindsTheSameWayAsTheFormBody() { using var db = await WithProfileAsync(6, "moderator"); var api = NewApi(db, 6); SetBody(api, """{"WarnedPlayerId":"208","ReportCategory":"103","DisplayReason":"griefing","ModeratorNote":"n"}""", "application/json"); Assert.IsType(await api.CreateWarning()); var warning = await db.PlayerWarnings.SingleAsync(); Assert.Equal(208, warning.WarnedPlayerId); Assert.Equal(ReportCategory.CoC_Trolling, warning.ReportCategory); Assert.Equal("griefing", warning.DisplayReason); } [Fact] public async Task AWarningWithoutAWarnedPlayerIsRefused() { using var db = await WithProfileAsync(6, "moderator"); var api = NewApi(db, 6); SetBody(api, "ModeratorNote=dfg", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); Assert.Empty(db.PlayerWarnings); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("gameClient")] public async Task AValidTokenWithoutAStaffRoleIsRefusedAndNothingIsWritten(string? roles) { using var db = await WithProfileAsync(9, roles); var api = NewApi(db, 9); SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded"); var result = Assert.IsType(await api.CreateWarning()); Assert.Equal(403, result.StatusCode); Assert.Empty(db.PlayerWarnings); } [Fact] public async Task APlayerWithNoProfileRowCannotWarnAnyone() { using var db = NewDb(); var api = NewApi(db, 6); SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded"); var result = Assert.IsType(await api.CreateWarning()); Assert.Equal(403, result.StatusCode); } // ----------------------------------------------------------------------------------- // Delivering a warning, and acknowledging it // ----------------------------------------------------------------------------------- [Fact] public async Task APendingWarningRidesTheNoBanBlockDetailsResponseAsTheWarnScreen() { using var db = await WithProfileAsync(6, "moderator"); var api = NewApi(db, 6); SetBody(api, "WarnedPlayerId=205&ReportCategory=101&DisplayReason=Sexual gestures", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); var warned = NewApi(db, 205); var ok = Assert.IsType(await warned.GetBlockDetails()); using var json = JsonDocument.Parse(Serialize(ok.Value)); Assert.True(json.RootElement.GetProperty("IsWarning").GetBoolean()); Assert.False(json.RootElement.GetProperty("IsBan").GetBoolean()); // Duration drives the BAN screen whenever it is > 0, so a warning must leave it at 0 or // the player is shown a ban countdown for a warning. Assert.Equal(0, json.RootElement.GetProperty("Duration").GetInt32()); Assert.Equal("Sexual gestures", json.RootElement.GetProperty("Message").GetString()); Assert.Equal(6, json.RootElement.GetProperty("PlayerIdReporter").GetInt64()); } [Fact] public async Task AcknowledgingClearsOnlyTheCallersPendingWarnings() { using var db = await WithProfileAsync(6, "moderator"); foreach (var warned in new[] { 205L, 206L }) { var api = NewApi(db, 6); SetBody(api, $"WarnedPlayerId={warned}", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); } // The acknowledge posts no body at all on some builds, so this call sends none. var result = Assert.IsType(await NewApi(db, 205).AcknowledgeWarning()); using var json = JsonDocument.Parse(Serialize(result.Value)); Assert.True(json.RootElement.GetProperty("Success").GetBoolean()); Assert.NotNull((await db.PlayerWarnings.SingleAsync(x => x.WarnedPlayerId == 205)).AcknowledgedAt); Assert.Null((await db.PlayerWarnings.SingleAsync(x => x.WarnedPlayerId == 206)).AcknowledgedAt); } [Fact] public async Task AnAcknowledgedWarningLeavesThePlainEmptyBlockDetails() { using var db = await WithProfileAsync(6, "moderator"); var api = NewApi(db, 6); SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); Assert.IsType(await NewApi(db, 205).AcknowledgeWarning()); var ok = Assert.IsType(await NewApi(db, 205).GetBlockDetails()); using var json = JsonDocument.Parse(Serialize(ok.Value)); // Back to the four-key empty object the no-ban response has always been. Assert.Equal(4, json.RootElement.EnumerateObject().Count()); Assert.Equal(0, json.RootElement.GetProperty("Duration").GetInt32()); } [Fact] public async Task ABanOutranksAPendingWarning() { using var db = await WithProfileAsync(6, "moderator"); var api = NewApi(db, 6); SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); db.Moderation.Add(new ModerationDB { PlayerId = 205, IsBan = true, Message = "banned", Category = ReportCategory.Misc, ExpireDuration = DateTime.UtcNow.AddHours(1) }); await db.SaveChangesAsync(); var ok = Assert.IsType(await NewApi(db, 205).GetBlockDetails()); using var json = JsonDocument.Parse(Serialize(ok.Value)); Assert.False(json.RootElement.GetProperty("IsWarning").GetBoolean()); Assert.True(json.RootElement.GetProperty("IsBan").GetBoolean()); Assert.True(json.RootElement.GetProperty("Duration").GetInt32() > 0); } // ----------------------------------------------------------------------------------- // Delivering it: out of the room, into the dorm, and NOT as a chat message // ----------------------------------------------------------------------------------- /// /// The static factories RoomUtils and MatchmakingUtils reach for when they are /// called from a request that only has its own context - the same pattern the other /// factory-dependent suites use. /// private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options; public TestDbContextFactory() { options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; } public RecDBContext CreateDbContext() => new(options); } /// /// Room 1 (the dorm template, cloned per player) plus a public room the player is standing /// in - the only state that makes an ejection observable. /// private static async Task<(TestDbContextFactory Factory, RecDBContext Db, RoomInstance Room)> APlayerInARoomAsync(long playerId, bool alreadyInTheirOwnDorm = false) { var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; var db = factory.CreateDbContext(); db.Rooms.Add(new RoomData { RoomId = 1, Name = "DormRoom", IsDorm = true, CreatorAccountId = 1, Accessibility = Accessibility.Private, MaxPlayers = 4, CreatedAt = DateTime.UtcNow, }); db.SubRooms.Add(new SubRoom { RoomId = 1, Name = "DormRoom", UnitySceneId = "DormRoomScene", MaxPlayers = 4, Accessibility = Accessibility.Private, DataBlob = "", }); db.Rooms.Add(new RoomData { RoomId = 25, Name = "RecCenter", CreatorAccountId = 1, Accessibility = Accessibility.Public, MaxPlayers = 20, CreatedAt = DateTime.UtcNow, }); db.SubRooms.Add(new SubRoom { RoomId = 25, Name = "Home", UnitySceneId = "RecCenterScene", MaxPlayers = 20, Accessibility = Accessibility.Public, }); // The player's own dorm, for the "already in it" case: a private room they created. if (alreadyInTheirOwnDorm) { db.Rooms.Add(new RoomData { RoomId = 77, Name = "DormRoom", IsDorm = true, CreatorAccountId = playerId, Accessibility = Accessibility.Private, MaxPlayers = 4, CreatedAt = DateTime.UtcNow, }); db.SubRooms.Add(new SubRoom { RoomId = 77, Name = "DormRoom", UnitySceneId = "DormRoomScene", MaxPlayers = 4, Accessibility = Accessibility.Private, DataBlob = "", }); } var instance = new RoomInstance { roomId = alreadyInTheirOwnDorm ? 77 : 25, subRoomId = 0, roomInstanceType = RoomInstanceType.Public, isPrivate = alreadyInTheirOwnDorm, maxCapacity = 20, name = alreadyInTheirOwnDorm ? "^DormRoom" : "^RecCenter", location = alreadyInTheirOwnDorm ? "DormRoomScene" : "RecCenterScene", }; db.RoomInstances.Add(instance); await db.SaveChangesAsync(); db.Presences.Add(new RealPresence { playerId = playerId, isOnline = true, roomInstance = instance.roomInstanceId, errorCode = JoinStatus.Success, }); await db.SaveChangesAsync(); return (factory, db, instance); } /// The room a player's presence currently points at, with the room row behind it. private static async Task<(RoomInstance Instance, RoomData Room)> WhereIsAsync(RecDBContext db, long playerId) { var presence = await db.Presences.AsNoTracking().FirstAsync(x => x.playerId == playerId); var instance = await db.RoomInstances.AsNoTracking().FirstAsync(x => x.roomInstanceId == presence.roomInstance); var room = await db.Rooms.AsNoTracking().FirstAsync(x => x.RoomId == instance.roomId); return (instance, room); } [Fact] public async Task AWarningIsNotDeliveredAsACoachMessage() { var (factory, db, _) = await APlayerInARoomAsync(205); // Notify.DbFactory is wired to the SAME store on purpose. With it null, SendMessage returns // immediately and the assertion below would pass even if a warning still tried to deliver // itself as a coach message; with it pointed somewhere else, the row would land in the other // database. Pointing it at this store is what makes "no message was written" mean it. var previousFactory = Notify.DbFactory; try { Notify.DbFactory = factory; await WithModeratorAsync(db, warnedPlayerId: 205); Assert.Empty(await db.Messages.ToListAsync()); // Positive control: this exact wiring does see a message when one is sent, so the empty // result above is the warning's doing rather than a harness that cannot see writes. await Notify.SendMessage(205, new Message { Type = MessageType.CoachMessage, Data = "control", FromPlayerId = 1, }); Assert.Single(await db.Messages.ToListAsync()); } finally { Notify.DbFactory = previousFactory; } } [Fact] public async Task AWarningTakesThePlayerOutOfTheRoomTheyAreInAndIntoTheirDorm() { const long playerId = 205; var (_, db, wasIn) = await APlayerInARoomAsync(playerId); await WithModeratorAsync(db, warnedPlayerId: playerId); var (nowIn, room) = await WhereIsAsync(db, playerId); // Out of the room they were warned in... Assert.NotEqual(wasIn.roomInstanceId, nowIn.roomInstanceId); // ...and standing in their own dorm, which is what makes them face the warn screen on the // room load that follows. Assert.True(room.IsDorm); Assert.Equal(playerId, room.CreatorAccountId); } [Fact] public async Task AWarningDoesNotBounceAPlayerOutOfTheirOwnDorm() { const long playerId = 206; var (_, db, wasIn) = await APlayerInARoomAsync(playerId, alreadyInTheirOwnDorm: true); await WithModeratorAsync(db, warnedPlayerId: playerId); var (nowIn, room) = await WhereIsAsync(db, playerId); Assert.Equal(wasIn.roomInstanceId, nowIn.roomInstanceId); Assert.True(room.IsDorm); } /// A moderator issues a warning to through the route. private static async Task WithModeratorAsync(RecDBContext db, long warnedPlayerId) { // Only once: the moderator row has accountId as its key, so a second call for a second // warning would be a duplicate insert rather than a second warn. if (!await db.Profiles.AnyAsync(x => x.accountId == 6)) { db.Profiles.Add(new Profile { accountId = 6, role = "moderator", username = "mod", displayName = "Mod", profileImage = DBConfig.DefaultProfileImage }); await db.SaveChangesAsync(); } var api = NewApi(db, 6); SetBody(api, $"WarnedPlayerId={warnedPlayerId}&ReportCategory=101", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); } /// Adds the developer role a target needs to be asked before anything is applied. private static async Task WithDeveloperProfileAsync(RecDBContext db, long accountId) { db.Profiles.Add(new Profile { accountId = accountId, role = "developer", username = $"dev{accountId}", displayName = $"Dev {accountId}", profileImage = DBConfig.DefaultProfileImage }); await db.SaveChangesAsync(); } /// Posts one voteToKick body as and requires it to be accepted. private static async Task AnswerVoteKickAsync(RecDBContext db, long callerId, string body) { var api = NewApi(db, callerId); SetBody(api, body, "application/x-www-form-urlencoded"); // The route is declared ActionResult, so the object result is on .Result. var result = await api.VoteToKick(); var ok = Assert.IsType(result.Result); Assert.True(((OkResponse)ok.Value!).Success); } // ----------------------------------------------------------------------------------- // Cannot be bypassed: a pending warning holds the player in their dorm until they // acknowledge it. This is the whole point of the feature - the row alone is not enough, // because leaving the dorm used to clear nothing and block nothing. // ----------------------------------------------------------------------------------- [Fact] public async Task APendingWarningAnswersTheRoomJoinWithTheirDormInsteadOfTheRoomTheyAskedFor() { const long playerId = 220; var (_, db, wasIn) = await APlayerInARoomAsync(playerId); await WithModeratorAsync(db, warnedPlayerId: playerId); // The play-menu join, which is also what goto/room/{name} and matchmake/room/{id} run. var match = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "RecCenter", playerId); // Answered as a successful matchmake into the dorm rather than as an error, so the client // loads the dorm it is already in with the warning screen still up. Assert.Equal(JoinStatus.Success, match.errorCode); Assert.NotNull(match.roomInstance); var (nowIn, room) = await WhereIsAsync(db, playerId); Assert.True(room.IsDorm); Assert.Equal(playerId, room.CreatorAccountId); Assert.Equal(match.roomInstance!.roomInstanceId, nowIn.roomInstanceId); Assert.NotEqual(wasIn.roomInstanceId, nowIn.roomInstanceId); } [Fact] public async Task APendingWarningAlsoHoldsTheInstanceIdJoinsShut() { const long playerId = 221; var (_, db, wasIn) = await APlayerInARoomAsync(playerId); await WithModeratorAsync(db, warnedPlayerId: playerId); // JoinRoom is the funnel behind a friend's presence, an invite, a deep link, a clubhouse and // an event - the doors that used to let a warned player walk straight back out. var match = await MatchmakingUtils.JoinRoom(playerId, wasIn.roomInstanceId); var (nowIn, room) = await WhereIsAsync(db, playerId); Assert.True(room.IsDorm); Assert.NotEqual(wasIn.roomInstanceId, nowIn.roomInstanceId); Assert.Equal(match.roomInstance!.roomInstanceId, nowIn.roomInstanceId); } [Fact] public async Task AcknowledgingIsWhatLiftsTheHoldNotLeavingTheDorm() { const long playerId = 222; var (_, db, _) = await APlayerInARoomAsync(playerId); await WithModeratorAsync(db, warnedPlayerId: playerId); // Still pending: the warning was delivered, not answered. Assert.True(await db.HasPendingWarningAsync(playerId)); var held = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "RecCenter", playerId); Assert.True((await db.Rooms.AsNoTracking().FirstAsync(x => x.RoomId == held.roomInstance!.roomId)).IsDorm); Assert.IsType(await NewApi(db, playerId).AcknowledgeWarning()); var released = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "RecCenter", playerId); var room = await db.Rooms.AsNoTracking().FirstAsync(x => x.RoomId == released.roomInstance!.roomId); Assert.Equal("RecCenter", room.Name); } [Fact] public async Task APendingWarningStillLetsThePlayerIntoTheirOwnDorm() { const long playerId = 223; var (_, db, _) = await APlayerInARoomAsync(playerId, alreadyInTheirOwnDorm: true); await WithModeratorAsync(db, warnedPlayerId: playerId); // Their own dorm is where the warning is answered, so it is not held shut - a warned player // has to be able to get back into the room the warning screen lives in. var match = await MatchmakingUtils.Matchmake(JoinMode.PublicMatchmaking, "DormRoom", playerId); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.NotNull(match.roomInstance); var room = await db.Rooms.AsNoTracking().FirstAsync(x => x.RoomId == match.roomInstance!.roomId); Assert.True(room.IsDorm); Assert.Equal(playerId, room.CreatorAccountId); } [Fact] public void TheServedWarningKeepsDurationAtZeroAndOnlyTheDeliveryCarriesTheScreenHold() { var warning = new PlayerWarning { Id = 1, ModeratorPlayerId = 6, WarnedPlayerId = 205, DisplayReason = "Sexual gestures", }; var served = PlayerUtils.BuildWarningBlockDetails(warning); var delivered = PlayerUtils.BuildWarningBlockDetails(warning, (int)PlayerUtils.WarningScreenHold.TotalSeconds); // What moderationBlockDetails answers with: the warn screen's text, and no duration - the // pending state is not a timeout. Assert.True(served.IsWarning); Assert.False(served.IsBan); Assert.Equal(0, served.Duration); Assert.Equal("Sexual gestures", served.Message); // What is pushed at the client: the client only holds the dorm's screen up (and so only ever // asks its warn screen for text) when the delivered frame carries Duration > 0. IsBan stays // false, which is what keeps it the warn screen and not the ban screen. Assert.True(delivered.IsWarning); Assert.False(delivered.IsBan); Assert.True(delivered.Duration > 0); Assert.Equal("Sexual gestures", delivered.Message); } [Fact] public async Task AWarningIssuedWhileThePlayerIsOfflineStaysPending() { using var db = await WithProfileAsync(6, "moderator"); // Player 205 has no presence row at all, and no notify connection is registered, so both the // presence push and the two frames are dropped. The warning is written BEFORE either, which // is why a player who is offline when they are warned is still held when they come back. await WithModeratorAsync(db, 205); Assert.True(await db.HasPendingWarningAsync(205)); Assert.Empty(await db.Messages.ToListAsync()); } [Fact] public async Task WarningsStackAndOneAcknowledgementClearsThemAll() { using var db = await WithProfileAsync(6, "moderator"); await WithModeratorAsync(db, 224); await WithModeratorAsync(db, 224); var rows = await db.PlayerWarnings.Where(x => x.WarnedPlayerId == 224).ToListAsync(); Assert.Equal(2, rows.Count); Assert.All(rows, row => Assert.Null(row.AcknowledgedAt)); Assert.IsType(await NewApi(db, 224).AcknowledgeWarning()); Assert.False(await db.HasPendingWarningAsync(224)); } [Fact] public async Task ASecondAcknowledgementIsANoOpRatherThanAnError() { using var db = await WithProfileAsync(6, "moderator"); await WithModeratorAsync(db, 225); Assert.IsType(await NewApi(db, 225).AcknowledgeWarning()); // The button is already pressed, or the screen was re-opened after it closed: the second // post has nothing left to clear and must still succeed, or the client hangs on its screen. Assert.IsType(await NewApi(db, 225).AcknowledgeWarning()); Assert.NotNull((await db.PlayerWarnings.SingleAsync()).AcknowledgedAt); } // ----------------------------------------------------------------------------------- // Developers: the server asks, waits, and only then applies. // ----------------------------------------------------------------------------------- [Fact] public async Task AWarningForADeveloperIsParkedAndNothingIsWritten() { using var db = await WithProfileAsync(6, "moderator"); await WithDeveloperProfileAsync(db, 230); var api = NewApi(db, 6); SetBody(api, "WarnedPlayerId=230&ReportCategory=101&DisplayReason=Sexual gestures&ModeratorNote=dfg", "application/x-www-form-urlencoded"); Assert.IsType(await api.CreateWarning()); // Parked, not issued: a warning that exists is a warning that pins its player to the dorm, // and the developer has not decided whether to accept it. Assert.Empty(await db.PlayerWarnings.ToListAsync()); var decision = PlayerUtils.TakeDeveloperDecision(230); Assert.NotNull(decision); Assert.Equal(PlayerUtils.DeveloperDecisionKind.Warning, decision!.Kind); Assert.Equal("Sexual gestures", decision.DisplayReason); Assert.Equal("dfg", decision.ModeratorNote); Assert.Equal(6, decision.IssuedBy); } [Fact] public async Task ADeveloperAcceptingTheWarningGetsItWrittenAsTheModeratorsWarning() { using var db = await WithProfileAsync(6, "moderator"); await WithDeveloperProfileAsync(db, 231); await WithModeratorAsync(db, 231); await AnswerVoteKickAsync(db, 231, "PlayerId=231&Response=true&GameSessionId=9301"); var warning = await db.PlayerWarnings.SingleAsync(); Assert.Equal(231, warning.WarnedPlayerId); Assert.Equal(6, warning.ModeratorPlayerId); // the moderator who issued it, not the developer who accepted Assert.Equal(ReportCategory.CoC_Sexual, warning.ReportCategory); // Accepting is not acknowledging: the warning is delivered now and stays pending until the // developer presses acknowledge on the warn screen. Assert.Null(warning.AcknowledgedAt); Assert.True(await db.HasPendingWarningAsync(231)); } [Fact] public async Task ADeveloperDenyingTheWarningLeavesNothingBehind() { using var db = await WithProfileAsync(6, "moderator"); await WithDeveloperProfileAsync(db, 232); await WithModeratorAsync(db, 232); await AnswerVoteKickAsync(db, 232, "PlayerId=232&Response=false&GameSessionId=9302"); // Deny means deny: no warning to hold them, no moderation row, nothing pending to clear. Assert.Empty(await db.PlayerWarnings.ToListAsync()); Assert.Empty(await db.Moderation.ToListAsync()); Assert.False(await db.HasPendingWarningAsync(232)); Assert.Null(PlayerUtils.TakeDeveloperDecision(232)); } [Fact] public async Task ARepeatedAnswerCannotApplyTheWarningTwice() { using var db = await WithProfileAsync(6, "moderator"); await WithDeveloperProfileAsync(db, 233); await WithModeratorAsync(db, 233); await AnswerVoteKickAsync(db, 233, "PlayerId=233&Response=true&GameSessionId=9303"); await AnswerVoteKickAsync(db, 233, "PlayerId=233&Response=true&GameSessionId=9303"); Assert.Single(await db.PlayerWarnings.ToListAsync()); } /// /// A parked prompt whose developer is not connected can no longer be answered, so it must not /// keep the slot. It used to: the client that would have shown the dialog was gone, nothing was /// written when the action was parked, and every later warning aimed at that developer was /// dropped with only a console line - the panel reported a prompt as sent and the developer's /// client was never asked anything again for the life of the process. /// [Fact] public async Task ADeveloperPromptNothingCouldReceiveIsReplacedInsteadOfWedging() { using var db = await WithProfileAsync(6, "moderator"); await WithDeveloperProfileAsync(db, 234); var warning = new PlayerWarning { WarnedPlayerId = 234, DisplayReason = "Trolling" }; Assert.True(await PlayerUtils.DeferForDeveloperAsync( db, 234, PlayerUtils.DeveloperDecisionKind.Warning, PlayerUtils.BuildWarningBlockDetails(warning, 60))); Assert.Null(Notify.GetClient(234)); // no live notify connection anywhere in this test var kick = PlayerUtils.BuildBlockDetails(true, ReportCategory.VoteKick, "kicked", DateTime.UtcNow.AddMinutes(15)); Assert.True(await PlayerUtils.DeferForDeveloperAsync(db, 234, PlayerUtils.DeveloperDecisionKind.Kick, kick)); // The replacement is what the developer is asked about, so their answer applies the action // the moderator just issued instead of the one nobody could have read. var decision = PlayerUtils.TakeDeveloperDecision(234); Assert.NotNull(decision); Assert.Equal(PlayerUtils.DeveloperDecisionKind.Kick, decision!.Kind); } /// /// The other half of the same rule: a developer who IS connected keeps the prompt they were /// already shown. Replacing a readable prompt would mean the developer reads one action and /// their answer applies a different one - the client answers with only the player's id. /// [Fact] public async Task AConnectedDeveloperKeepsThePromptTheyWereAlreadyShown() { using var db = await WithProfileAsync(6, "moderator"); await WithDeveloperProfileAsync(db, 235); var client = new HubClient { AccountId = 235, ConnectionId = "warning-test-235" }; Notify.Clients.Add(client); try { var warning = new PlayerWarning { WarnedPlayerId = 235, DisplayReason = "Trolling" }; Assert.True(await PlayerUtils.DeferForDeveloperAsync( db, 235, PlayerUtils.DeveloperDecisionKind.Warning, PlayerUtils.BuildWarningBlockDetails(warning, 60))); var kick = PlayerUtils.BuildBlockDetails(true, ReportCategory.VoteKick, "kicked", DateTime.UtcNow.AddMinutes(15)); Assert.True(await PlayerUtils.DeferForDeveloperAsync(db, 235, PlayerUtils.DeveloperDecisionKind.Kick, kick)); Assert.Equal(PlayerUtils.DeveloperDecisionKind.Warning, PlayerUtils.TakeDeveloperDecision(235)!.Kind); } finally { Notify.Clients.Remove(client); } } [Fact] public async Task AnAnswerWithNothingParkedIsStable() { using var db = await WithProfileAsync(6, "moderator"); await WithDeveloperProfileAsync(db, 234); // A late answer (the decision expired), a duplicate, or a response that arrives after the // player reconnected: nothing is parked, so there is nothing to apply and nothing to crash. await AnswerVoteKickAsync(db, 234, "PlayerId=234&Response=true&GameSessionId=9304"); Assert.Empty(await db.PlayerWarnings.ToListAsync()); Assert.Empty(await db.Moderation.ToListAsync()); } [Fact] public async Task AVoteKickThatReachesADeveloperIsParkedUntilTheyAcceptIt() { using var db = await WithProfileAsync(6, "moderator"); await WithDeveloperProfileAsync(db, 235); const string vote = "PlayerId=235&Response=true&GameSessionId=9305&Reason=Griefing"; foreach (var voter in new[] { 301L, 302L, 303L }) await AnswerVoteKickAsync(db, voter, vote); // The threshold was reached against a developer and nothing happened to them yet. Assert.Empty(await db.Moderation.ToListAsync()); await AnswerVoteKickAsync(db, 235, "PlayerId=235&Response=true&GameSessionId=9305"); // Accepted: the kick is applied for real, which is the delivery the park skipped. Assert.Single(await db.Moderation.ToListAsync()); } // ----------------------------------------------------------------------------------- // The write envelope // ----------------------------------------------------------------------------------- [Fact] public async Task TheWriteEnvelopeAnswersBothKeyCasingsWithAnEmptyError() { using var db = await WithProfileAsync(6, "moderator"); var api = NewApi(db, 6); SetBody(api, "WarnedPlayerId=205", "application/x-www-form-urlencoded"); var ok = Assert.IsType(await api.CreateWarning()); using var json = JsonDocument.Parse(Serialize(ok.Value)); // This server answers its report write in PascalCase; the reference implementation // documents lowercase for this one. Newtonsoft ignores what it has no member for, so both // are served rather than betting on one. Assert.True(json.RootElement.GetProperty("Success").GetBoolean()); Assert.True(json.RootElement.GetProperty("success").GetBoolean()); Assert.Equal("", json.RootElement.GetProperty("Error").GetString()); Assert.Equal("", json.RootElement.GetProperty("error").GetString()); } // ----------------------------------------------------------------------------------- // Releasing the hold, and the reason the warn screen is filed under // ----------------------------------------------------------------------------------- /// /// The frame an acknowledgement pushes is the "nothing is pending against you" state, not a /// second warning. The client's dorm hold is a coroutine started by the duration on the kick /// frame, and the state it is handed on acknowledgement is what it renders: a cleared block /// must therefore raise neither the warn screen nor the ban screen - IsWarning and IsBan both /// false, Duration 0 - or acknowledging a warning would re-raise a moderation screen instead /// of closing one. /// [Fact] public void TheAcknowledgedStateRaisesNeitherScreen() { var cleared = PlayerUtils.BuildClearedBlockDetails(); Assert.False(cleared.IsWarning); Assert.False(cleared.IsBan); Assert.False(cleared.IsHostKick); Assert.Equal(0, cleared.Duration); Assert.Equal(string.Empty, cleared.Message); Assert.Equal(ReportCategory.Unknown, cleared.ReportCategory); } /// /// The sixth Code of Conduct reason has to be 200, because that is the number the 2023 /// client's category enum carries for it (KOFEKCMPOEK.DHGJGCOMKOM.InappropriateClothing = 200) /// and the warn screen picks the reason it renders from that number. A different value would /// file the warning correctly on the server and show the player the wrong reason - and the /// admin panel's warn route validates on the same enum, so it has to be defined on both. /// [Fact] public void TheCustomClothingReasonIsTheNumberOfTheClientEnum() { Assert.Equal(200, (int)ReportCategory.InappropriateClothing); Assert.Equal(200, (int)KickReason.InappropriateClothing); // What the panel's category validation (Enum.IsDefined) and the warn write both read. Assert.True(Enum.IsDefined(typeof(ReportCategory), 200)); Assert.True(Enum.IsDefined(typeof(KickReason), 200)); // The five original reasons keep the numbers they already had: the panel and the client // both address them by number. Assert.Equal(100, (int)ReportCategory.CoC_Underage); Assert.Equal(101, (int)ReportCategory.CoC_Sexual); Assert.Equal(102, (int)ReportCategory.CoC_Discrimination); Assert.Equal(103, (int)ReportCategory.CoC_Trolling); Assert.Equal(104, (int)ReportCategory.CoC_NameOrProfile); } }