mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
183 lines
7.1 KiB
C#
183 lines
7.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Vote-to-kick, specifically the developer case (<c>POST api/PlayerReporting/v2/voteToKick</c>).
|
|
///
|
|
/// 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.
|
|
/// </summary>
|
|
[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}}"}""";
|
|
|
|
/// <summary>One vote, from a distinct voter (the vote count is per voter, not per call).</summary>
|
|
private static async Task VoteAsync(RecDBContext db, long voterId, string body)
|
|
{
|
|
var api = NewApi(db, voterId);
|
|
SetJson(api, body);
|
|
|
|
// The action returns ActionResult<OkResponse>, 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<OkObjectResult>(result.Result);
|
|
}
|
|
|
|
private static async Task<RecDBContext> 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);
|
|
}
|
|
}
|