mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
267 lines
8.9 KiB
C#
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);
|
|
}
|
|
}
|