Files
Radicade/ServerTests/BanAppealResolveTests.cs
T
2026-10-16 23:33:13 -07:00

267 lines
8.9 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using RadicadeServer.API.Moderation;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Enums.Moderation;
using RadicadeServer.RecNet.Moderation;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// The support site closes a ticket by telling the game what it decided, so the
/// game endpoint behind that call is the only thing standing between a queue
/// item and a player's appeal state.
///
/// A ban appeal used to have exactly two verdicts, accept and deny. That left a
/// ticket which could be neither accepted nor denied with no way out of the
/// queue, and left staff choosing between lying about a verdict or never
/// clearing the ticket. Reviewing is the third verdict: the ban stands, the
/// appeal stops being pending, and the cooldown starts so the player can appeal
/// again later instead of waiting on a decision that will never come.
///
/// These tests pin the state each verdict leaves behind, because a wrong field
/// here is invisible until a player is locked out of appealing.
/// </summary>
[Collection(DbFactoryCollection.Name)]
public class BanAppealResolveTests
{
private const long PlayerId = 4242;
private const string AppealCode = "NWVPZX5Q";
private const string SupportKey = "support-test-key";
/* A named in-memory store plus its root, so separate contexts in one test
really do share a database. That is what makes the assertions below check
what was saved rather than what happens to be attached to one context. */
private sealed class TestStore
{
private readonly string _name = Guid.NewGuid().ToString();
private readonly InMemoryDatabaseRoot _root = new();
public RecDBContext Open() => new(
TestDbOptions.Create()
.UseInMemoryDatabase(_name, _root)
.Options);
}
private static ModerationDB Ban(string? appealText) => new()
{
PlayerId = PlayerId,
IsBan = true,
Message = "Cheating",
Category = ReportCategory.CoC_Trolling,
CreatedAt = DateTime.UtcNow.AddDays(-1),
ExpireDuration = DateTime.UtcNow.AddDays(30),
AppealCode = AppealCode,
AppealText = appealText
};
private static async Task<TestStore> SeedAsync(string? appealText)
{
var store = new TestStore();
await using var db = store.Open();
db.Moderation.Add(Ban(appealText));
await db.SaveChangesAsync();
return store;
}
private static async Task<ActionResult<object>> ResolveAsync(TestStore store, string? decision)
{
var previous = DBConfig.AdminApiKey;
DBConfig.AdminApiKey = SupportKey;
try
{
await using var db = store.Open();
var controller = new BanAppealAPI(db);
return await controller.Resolve(AppealCode, SupportKey, decision);
}
finally
{
DBConfig.AdminApiKey = previous;
}
}
private static async Task<ModerationDB> ReloadAsync(TestStore store)
{
await using var db = store.Open();
return await db.Moderation.AsNoTracking().SingleAsync(x => x.PlayerId == PlayerId);
}
/* AppealResponse ships an anonymous { success, error, value }, so the payload
is read reflectively rather than by adding a type just for tests. */
private static (bool Success, string? Error, string? Value) Read(ActionResult<object> result)
{
var payload = result.Result switch
{
ObjectResult objectResult => objectResult.Value,
_ => null
};
Assert.NotNull(payload);
var type = payload!.GetType();
var success = (bool)(type.GetProperty("success")!.GetValue(payload) ?? false);
var error = type.GetProperty("error")!.GetValue(payload) as string;
var value = type.GetProperty("value")!.GetValue(payload) as string;
return (success, error, value);
}
[Fact]
public async Task ReviewingAnAppealLeavesTheBanAndEndsThePendingState()
{
var store = await SeedAsync(PlayerUtils.SupportSiteAppealText);
var (success, error, value) = Read(await ResolveAsync(store, "reviewed"));
Assert.True(success, error);
Assert.Equal("Reviewed", value);
var ban = await ReloadAsync(store);
Assert.True(ban.IsBan);
Assert.Equal("Reviewed", ban.AppealResolution);
Assert.NotNull(ban.AppealResolvedAt);
/* The appeal body stays as the player wrote it: it is the record of what
was reviewed, not a flag to be cleared. */
Assert.Equal(PlayerUtils.SupportSiteAppealText, ban.AppealText);
}
/* The point of the verdict: a reviewed appeal is no longer pending, which is
what the ban screen keys off, and the cooldown now applies so the player
gets a fresh code once it lapses. */
[Fact]
public async Task AReviewedAppealNoLongerLooksPendingAndStartsTheCooldown()
{
var store = await SeedAsync(PlayerUtils.SupportSiteAppealText);
Read(await ResolveAsync(store, "review"));
var ban = await ReloadAsync(store);
var pending = ban.AppealText != null && ban.AppealResolution == null;
Assert.False(pending);
await using var db = store.Open();
var cooldown = await PlayerUtils.GetAppealCooldownErrorAsync(db, PlayerId);
Assert.NotNull(cooldown);
}
[Fact]
public async Task AcceptingAnAppealLiftsTheBan()
{
var store = await SeedAsync(PlayerUtils.SupportSiteAppealText);
var (success, error, value) = Read(await ResolveAsync(store, "accept"));
Assert.True(success, error);
Assert.Equal("Accepted", value);
var ban = await ReloadAsync(store);
Assert.False(ban.IsBan);
Assert.Equal("Accepted", ban.AppealResolution);
}
[Fact]
public async Task DenyingAnAppealKeepsTheBanAndStartsTheCooldown()
{
var store = await SeedAsync(PlayerUtils.SupportSiteAppealText);
var (success, error, value) = Read(await ResolveAsync(store, "deny"));
Assert.True(success, error);
Assert.Equal("Rejected", value);
var ban = await ReloadAsync(store);
Assert.True(ban.IsBan);
Assert.Equal("Rejected", ban.AppealResolution);
Assert.NotNull(ban.AppealResolvedAt);
}
/* A second click, a retry after a timeout, or a decision the admin panel
already made must never overwrite the verdict that is already there. */
[Fact]
public async Task AnExistingVerdictIsNeverOverwritten()
{
var store = await SeedAsync(PlayerUtils.SupportSiteAppealText);
Read(await ResolveAsync(store, "deny"));
var (success, error, value) = Read(await ResolveAsync(store, "reviewed"));
Assert.True(success, error);
Assert.Equal("Rejected", value);
Assert.Equal("Rejected", (await ReloadAsync(store)).AppealResolution);
}
[Fact]
public async Task AnUnknownDecisionChangesNothing()
{
var store = await SeedAsync(PlayerUtils.SupportSiteAppealText);
var (success, error, value) = Read(await ResolveAsync(store, "maybe"));
Assert.False(success);
Assert.Equal("Unknown decision.", error);
Assert.Null(value);
var ban = await ReloadAsync(store);
Assert.True(ban.IsBan);
Assert.Null(ban.AppealResolution);
Assert.Null(ban.AppealResolvedAt);
}
[Fact]
public async Task TheWrongSecretIsRefusedWithoutTouchingTheAppeal()
{
var store = await SeedAsync(PlayerUtils.SupportSiteAppealText);
var previous = DBConfig.AdminApiKey;
DBConfig.AdminApiKey = SupportKey;
ActionResult<object> result;
try
{
await using var db = store.Open();
result = await new BanAppealAPI(db).Resolve(AppealCode, "not-the-key", "accept");
}
finally
{
DBConfig.AdminApiKey = previous;
}
Assert.IsType<UnauthorizedObjectResult>(result.Result);
var ban = await ReloadAsync(store);
Assert.True(ban.IsBan);
Assert.Null(ban.AppealResolution);
}
[Fact]
public async Task AnUnknownCodeIsRefusedWithoutTouchingAnything()
{
var store = await SeedAsync(PlayerUtils.SupportSiteAppealText);
var previous = DBConfig.AdminApiKey;
DBConfig.AdminApiKey = SupportKey;
ActionResult<object> result;
try
{
await using var db = store.Open();
result = await new BanAppealAPI(db).Resolve("NOSUCHCODE", SupportKey, "reviewed");
}
finally
{
DBConfig.AdminApiKey = previous;
}
var (success, error, _) = Read(result);
Assert.False(success);
Assert.Equal("Unknown appeal code.", error);
Assert.Null((await ReloadAsync(store)).AppealResolution);
}
}