using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using RadicadeServer.API.Moderation; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums.Moderation; using Xunit; namespace RadicadeServer.Tests; /// /// Vote-to-kick, specifically the developer case (POST api/PlayerReporting/v2/voteToKick). /// /// Three votes used to kick anyone, developers included, with no way back: the moderation row that /// blocks matchmaking was written the moment the threshold was reached, so a developer who never /// agreed to the kick was banned anyway and the kick could not be undone by declining. /// /// A developer's kick now STANDS DOWN until they accept it - nothing is written at the threshold, /// the kick is parked, their client is handed the prompt, and their answer on the same route /// (PlayerId = themselves) decides it: accept applies the kick, decline drops it and leaves the /// database exactly as it was. /// /// These pin the decision, which is the part that was wrong. The prompt itself is delivered by the /// same KickOrPromptAsync/Notify path the ban flow already uses. /// [Collection(DbFactoryCollection.Name)] public class VoteKickDeveloperTests { private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static PlayerReportingAPI NewApi(RecDBContext db, long callerId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, callerId.ToString())); return new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } } }; } private static void SetJson(Controller controller, string json) { var request = controller.ControllerContext.HttpContext.Request; request.ContentType = "application/json"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); } private static string VoteBody(long targetId, bool response, long sessionId, string reason = "Cheating") => $$"""{"PlayerId":{{targetId}},"Response":{{(response ? "true" : "false")}},"GameSessionId":{{sessionId}},"Reason":"{{reason}}"}"""; /// One vote, from a distinct voter (the vote count is per voter, not per call). private static async Task VoteAsync(RecDBContext db, long voterId, string body) { var api = NewApi(db, voterId); SetJson(api, body); // The action returns ActionResult, so Ok(...) arrives as its Result; a vote is // always a 200, including the one that decides a developer's parked kick. var result = await api.VoteToKick(); Assert.IsType(result.Result); } private static async Task WithTargetAsync(long targetId, string? roles) { var db = NewDb(); db.Profiles.Add(new Profile { accountId = targetId, role = roles, username = $"player{targetId}", displayName = $"Player {targetId}", profileImage = DBConfig.DefaultProfileImage }); await db.SaveChangesAsync(); return db; } // ----------------------------------------------------------------------------------- // The behaviour being kept: an ordinary player is kicked by the vote // ----------------------------------------------------------------------------------- [Fact] public async Task ANonDeveloperIsKickedAsSoonAsTheVotePasses() { using var db = await WithTargetAsync(205, roles: null); const long session = 910001; await VoteAsync(db, 1, VoteBody(205, true, session)); await VoteAsync(db, 2, VoteBody(205, true, session)); Assert.Empty(db.Moderation); await VoteAsync(db, 3, VoteBody(205, true, session)); var moderation = await db.Moderation.SingleAsync(); Assert.True(moderation.IsBan); Assert.Equal("Cheating", moderation.Message); } // ----------------------------------------------------------------------------------- // The behaviour that changed: a developer's kick waits for them // ----------------------------------------------------------------------------------- [Fact] public async Task ADevelopersVoteKickIsParkedInsteadOfApplied() { using var db = await WithTargetAsync(206, "developer"); const long session = 910002; await VoteAsync(db, 1, VoteBody(206, true, session)); await VoteAsync(db, 2, VoteBody(206, true, session)); await VoteAsync(db, 3, VoteBody(206, true, session)); // Nothing written: the moderation row is what blocks matchmaking, so writing it here is // exactly the "kicked a developer who never agreed to it" bug. Assert.Empty(db.Moderation); } [Fact] public async Task ADeveloperAcceptingTheParkedKickIsKickedWheneverTheyAnswer() { using var db = await WithTargetAsync(207, "developer"); const long session = 910003; for (long voter = 1; voter <= 3; voter++) await VoteAsync(db, voter, VoteBody(207, true, session, "Being AFK")); Assert.Empty(db.Moderation); // Their own answer on the same route: PlayerId is themselves. await VoteAsync(db, 207, VoteBody(207, true, session, "Being AFK")); var moderation = await db.Moderation.SingleAsync(); Assert.True(moderation.IsBan); Assert.Equal("Being AFK", moderation.Message); } [Fact] public async Task ADeveloperDecliningTheParkedKickLeavesNothingBehind() { using var db = await WithTargetAsync(208, "developer"); const long session = 910004; for (long voter = 1; voter <= 3; voter++) await VoteAsync(db, voter, VoteBody(208, true, session)); Assert.Empty(db.Moderation); await VoteAsync(db, 208, VoteBody(208, false, session)); // "Stands down" has to mean the database is untouched, not that a row was written and then // apologised for - the row is what stops them joining a room later. Assert.Empty(db.Moderation); // And the parked kick is gone, so a later answer cannot kick them out of nowhere. await VoteAsync(db, 208, VoteBody(208, true, session)); Assert.Empty(db.Moderation); } [Fact] public async Task ADevelopersOwnAnswerIsNotCountedAsAVote() { using var db = await WithTargetAsync(209, "developer"); const long session = 910005; // Answering a prompt they were never sent must not add a vote towards their own kick. await VoteAsync(db, 209, VoteBody(209, true, session)); await VoteAsync(db, 209, VoteBody(209, true, session)); await VoteAsync(db, 209, VoteBody(209, true, session)); Assert.Empty(db.Moderation); } }