using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Moderation; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Moderation; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; [Collection(DbFactoryCollection.Name)] public class ModerationBlockDetailsTests { private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static PlayerReportingAPI NewApi(RecDBContext db, long? accountId) { var identity = new ClaimsIdentity("TestAuth"); if (accountId.HasValue) identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.Value.ToString())); return new PlayerReportingAPI( db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } } }; } private static void AssertReportSucceeded(ActionResult result) { var ok = Assert.IsType(result.Result); Assert.True(Assert.IsType(ok.Value).Success); } private static async Task AddReporterAsync(RecDBContext db, long accountId, string? roles = null) { db.Profiles.Add(new Profile { accountId = accountId, username = $"reporter{accountId}", displayName = $"Reporter {accountId}", profileImage = DBConfig.DefaultProfileImage, role = roles }); await db.SaveChangesAsync(); } [Theory] [InlineData(0, false)] [InlineData(5, false)] [InlineData(6, true)] [InlineData(7, true)] public void AutomaticTimeoutRequiresMoreThanFiveReportsFromAPlayer(int reportCount, bool expected) { Assert.Equal(expected, PlayerReportingAPI.ShouldApplyAutomaticTimeout(reportCount, reporterIsModerator: false)); } [Fact] public async Task TheSixthPublicReportCreatesAnAutomaticTimeout() { using var db = NewDb(); await AddReporterAsync(db, 6); db.Reputations.Add(new Reputation { AccountId = 42, ReportCount = 5 }); await db.SaveChangesAsync(); AssertReportSucceeded(await NewApi(db, 6).CreateReport(new CreateReportRequest { PlayerIdReported = 42, ReportCategory = ReportCategory.CoC_Trolling, Details = "report details", RoomId = 0 })); var moderation = await db.Moderation.SingleAsync(); Assert.Equal("Automatic Timeout", moderation.BanMethod); Assert.Equal("You have received a timeout due to a high number of reports for Trolling.", moderation.Message); Assert.True(moderation.CreatedAt <= DateTime.UtcNow); Assert.Equal(6, (await db.Reputations.SingleAsync(x => x.AccountId == 42)).ReportCount); } [Fact] public async Task SelfAndUnauthenticatedReportsDoNotCountTowardAutomaticTimeouts() { using var db = NewDb(); db.Reputations.Add(new Reputation { AccountId = 42, ReportCount = 5 }); await db.SaveChangesAsync(); AssertReportSucceeded(await NewApi(db, 42).CreateReport(new CreateReportRequest { PlayerIdReported = 42, ReportCategory = ReportCategory.CoC_Trolling })); Assert.Empty(await db.Moderation.ToListAsync()); Assert.Equal(5, (await db.Reputations.SingleAsync(x => x.AccountId == 42)).ReportCount); AssertReportSucceeded(await NewApi(db, null).CreateReport(new CreateReportRequest { PlayerIdReported = 42, ReportCategory = ReportCategory.CoC_Trolling })); Assert.Empty(await db.Moderation.ToListAsync()); Assert.Equal(5, (await db.Reputations.SingleAsync(x => x.AccountId == 42)).ReportCount); } [Fact] public async Task ModeratorReportCreatesDirectTimeoutWithoutAlsoUsingPublicReportEscalation() { using var db = NewDb(); await AddReporterAsync(db, 6, "moderator"); db.Reputations.Add(new Reputation { AccountId = 42, ReportCount = 0 }); await db.SaveChangesAsync(); AssertReportSucceeded(await NewApi(db, 6).CreateReport(new CreateReportRequest { PlayerIdReported = 42, ReportCategory = ReportCategory.CoC_Trolling, Details = "Moderator observed trolling" })); var moderation = await db.Moderation.SingleAsync(); Assert.Equal("Moderator Report", moderation.BanMethod); Assert.Equal("Moderator observed trolling", moderation.Message); Assert.Equal(0, (await db.Reputations.SingleAsync(x => x.AccountId == 42)).ReportCount); } [Fact] public async Task InvalidReportCategoryIsRejectedBeforeItCounts() { using var db = NewDb(); await AddReporterAsync(db, 6); var result = await NewApi(db, 6).CreateReport(new CreateReportRequest { PlayerIdReported = 42, ReportCategory = (ReportCategory)9999 }); Assert.IsType(result.Result); Assert.Empty(await db.Reputations.ToListAsync()); } [Theory] [InlineData(ReportCategory.CoC_Trolling, "Trolling")] [InlineData(ReportCategory.CoC_Sexual, "Sexual behavior")] public void AutomaticTimeoutReasonUsesTheReportCategory(ReportCategory category, string expected) { Assert.Equal(expected, PlayerReportingAPI.GetAutomaticTimeoutReason(category)); } [Fact] public async Task VoiceModerationBlockDetailsCarryStartTimeButNotModeratorOnlyNote() { using var db = NewDb(); var startedAt = DateTime.UtcNow; db.Moderation.Add(new ModerationDB { PlayerId = 42, IsBan = true, Message = "Sexually explicit behavior or photos", ModeratorNote = "Sexual harassment in the Rec Center", BanMethod = "Voice Moderation", Category = ReportCategory.CoC_Sexual, CreatedAt = startedAt, ExpireDuration = startedAt.AddDays(1), AppealCode = "ABC12345" }); await db.SaveChangesAsync(); var api = NewApi(db, 42); api.ControllerContext.HttpContext.Request.Headers["X-Forwarded-For"] = "192.0.2.42"; var result = Assert.IsType(await api.GetBlockDetails()); var details = Assert.IsType(result.Value); Assert.Equal(startedAt, details.TimeoutStartedAt); Assert.Equal("VoiceModeration", details.BanType); Assert.Equal("Sexually explicit behavior or photos", details.Message); } [Theory] [InlineData("Automatic Timeout", "You have received a timeout due to a high number of reports for Trolling", "You have received a timeout due to a high number of reports for Trolling.")] [InlineData("Moderator Report", "You were reported by a moderator", "You have received a timeout. You were reported by a moderator.")] [InlineData("Moderator Report", "You were reported by a moderator.", "You have received a timeout. a moderator report.")] [InlineData("Timeout", "Trolling", "You have received a timeout due to a high number of reports for Trolling.")] [InlineData("Automatic Timeout", "You have received a timeout due to a high number of reports for Trolling.", "You have received a timeout due to a high number of reports for Trolling.")] public async Task PersistedTimeoutBanHasClientFacingTimeoutCopy(string banMethod, string storedReason, string expectedMessage) { using var db = NewDb(); var startedAt = DateTime.UtcNow; db.Moderation.Add(new ModerationDB { PlayerId = 42, IsBan = true, Message = storedReason, BanMethod = banMethod, Category = ReportCategory.CoC_Trolling, CreatedAt = startedAt, ExpireDuration = startedAt.AddHours(1), AppealCode = "ABC12345" }); await db.SaveChangesAsync(); var api = NewApi(db, 42); api.ControllerContext.HttpContext.Request.Headers["X-Forwarded-For"] = "192.0.2.42"; var result = Assert.IsType(await api.GetBlockDetails()); var details = Assert.IsType(result.Value); Assert.Equal("Timeout", details.BanType); Assert.Equal(startedAt, details.TimeoutStartedAt); Assert.Equal(expectedMessage, details.DisplayReason); Assert.Equal(expectedMessage, details.Message); } [Fact] public async Task HardwareBanShowsTheOriginalRelatedAccountOnItsOwnLine() { using var db = NewDb(); var startedAt = DateTime.UtcNow; db.Players.Add(new PlayerData { AccountId = 42, DeviceId = "shared-device" }); db.Profiles.Add(new Profile { accountId = 12, username = "HealthyMoose640", displayName = "HealthyMoose640", profileImage = DBConfig.DefaultProfileImage }); db.HardwareBans.Add(new HardwareBan { DeviceId = "shared-device", BannedAccountId = 12, Reason = "Moderator. Account ban", CreatedAt = startedAt, ExpiresAt = startedAt.AddDays(1) }); await db.SaveChangesAsync(); var api = NewApi(db, 42); api.ControllerContext.HttpContext.Request.Headers["X-Forwarded-For"] = "192.0.2.42"; var result = Assert.IsType(await api.GetBlockDetails()); var details = Assert.IsType(result.Value); Assert.Contains("\nRelated account: @HealthyMoose640", details.Message); Assert.Equal(startedAt, details.TimeoutStartedAt); } /* Every ban the client is shown has to carry the moment it started, because that is the "Banned since" line. Kicks must not: they are not bans and the screen has nothing to date. */ [Fact] public void BanBlockDetailsCarryBannedSinceWhileKicksDoNot() { var startedAt = DateTime.UtcNow.AddDays(-3); var ban = PlayerUtils.BuildBlockDetails(true, ReportCategory.Misc, "reason", startedAt.AddDays(5), startedAtUtc: startedAt); Assert.Equal(startedAt, ban.TimeoutStartedAt); var kick = PlayerUtils.BuildBlockDetails(true, ReportCategory.Misc, "kicked", DateTime.UtcNow.AddMinutes(15)); Assert.Null(kick.TimeoutStartedAt); } }