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

217 lines
7.9 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Template;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.Admin;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Accounts.Local;
using RadicadeServer.RecNet.Auth;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// The panel drives every player action through POST admin/panel/players/{id}/{kind}, and some of
/// those actions are named with a slash in them: the roles box's Add ("roles/add") and Clear
/// ("roles/clear"), plus settings/birthday, settings/delete, settings/reset and
/// settings/profilepicture. A slash-named action is two path segments, so routing hands the
/// controller kind="roles" and sub="add" - while the action the switch matches on is the rejoined
/// "roles/add". These tests hand the action its arguments exactly the way routing splits them, so
/// splitting the name again fails here instead of only in the deployed panel, where a miss shows
/// up as the toast "Gone" (the panel's name for 404).
/// </summary>
public class AdminPanelPlayerActionTests
{
private const string Secret = "test-bot-secret";
private const long Player = 2;
private static RecDBContext NewDb() => new(
TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
/// <summary>A database holding just the profile a player action needs.</summary>
private static async Task<RecDBContext> WithPlayer(string? roles)
{
var db = NewDb();
db.Profiles.Add(new Profile
{
accountId = Player,
username = "testplayer",
displayName = "Test Player",
profileImage = "",
role = roles
});
await db.SaveChangesAsync();
return db;
}
/// <summary>
/// Runs one slash-named action the way the route delivers it: the request is
/// POST admin/panel/players/{id}/{kind}/{sub}, so the controller receives the name in halves.
/// </summary>
private static async Task<IActionResult> SendAction(RecDBContext db, string kind, string sub, string query)
{
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?" + query);
var api = new AdminPanelAPI(db)
{
ControllerContext = new ControllerContext { HttpContext = context }
};
var original = DBConfig.AdminApiKey;
DBConfig.AdminApiKey = Secret;
try
{
return await api.PlayerPanelSubAction(Player, kind, sub, Secret);
}
finally
{
DBConfig.AdminApiKey = original;
}
}
private static async Task<Profile> StoredProfile(RecDBContext db)
=> await db.Profiles.SingleAsync(x => x.accountId == Player);
/// <summary>
/// The link every test here rests on: the URL the panel builds for a slash-named action -
/// POST admin/panel/players/{id}/roles/add - is the route whose last two segments are
/// {kind}/{sub}, and the values it hands the action really are kind="roles", sub="add".
/// If the route is ever reshaped so the panel's own URL no longer lands there, the actions
/// below would start passing for the wrong reason; this fails first.
/// </summary>
[Fact]
public void ThePanelsUrlForASlashNamedActionBindsKindAndSub()
{
var matcher = new TemplateMatcher(
TemplateParser.Parse("admin/panel/players/{playerId:long}/{kind}/{sub}"),
new RouteValueDictionary());
var values = new RouteValueDictionary();
Assert.True(matcher.TryMatch(new PathString("/admin/panel/players/2/roles/add"), values));
Assert.Equal("2", values["playerId"]);
Assert.Equal("roles", values["kind"]);
Assert.Equal("add", values["sub"]);
}
[Fact]
public async Task AddingRolesStoresThemOnThePlayer()
{
var db = await WithPlayer(null);
var result = await SendAction(db, "roles", "add", "roles=developer,moderator");
Assert.IsType<OkObjectResult>(result);
var profile = await StoredProfile(db);
Assert.Equal("developer,moderator", profile.role);
// The stored role is what the game reads, so the flags have to follow it.
Assert.True(profile.HasRole("moderator"));
Assert.False(profile.HasRole("influencer"));
}
[Fact]
public async Task AddingARoleKeepsTheRolesThePlayerAlreadyHad()
{
var db = await WithPlayer("creatorforum");
// The panel's boxes are ticked independently, so it can send a role the player already
// holds alongside new ones; that must not duplicate it or drop what was already there.
var result = await SendAction(db, "roles", "add", "roles=Developer,creatorforum,keepsake");
Assert.IsType<OkObjectResult>(result);
Assert.Equal("creatorforum,Developer,keepsake", (await StoredProfile(db)).role);
}
[Fact]
public async Task AddingWithNothingPickedIsRefusedRatherThanClearingTheRoles()
{
var db = await WithPlayer("developer");
// The panel keeps Add disabled until a box is ticked, but a lost pick must still be a
// refusal - "picked nothing" must never read as "clear what this player has".
var result = await SendAction(db, "roles", "add", "");
Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("developer", (await StoredProfile(db)).role);
}
[Fact]
public async Task ClearingRolesEmptiesThem()
{
var db = await WithPlayer("developer,moderator,keepsake");
var result = await SendAction(db, "roles", "clear", "");
Assert.IsType<OkObjectResult>(result);
var profile = await StoredProfile(db);
Assert.Equal("", profile.role);
Assert.False(profile.HasRole("developer"));
Assert.False(profile.HasRole("moderator"));
}
[Fact]
public async Task ClearingRolesOnAPlayerWithNoneLeftIsStillAccepted()
{
var db = await WithPlayer(null);
// Nothing to clear is not an error: the panel's confirm dialog already ran.
var result = await SendAction(db, "roles", "clear", "");
Assert.IsType<OkObjectResult>(result);
Assert.Equal("", (await StoredProfile(db)).role);
}
[Fact]
public async Task TheOtherSlashNamedActionsReachTheirCasesToo()
{
var db = await WithPlayer("developer");
// Fixing the split must lift every slash-named action, not just the roles box - the
// settings panels build their URLs the same way.
var result = await SendAction(db, "settings", "birthday", "birthday=2004-02-29");
Assert.IsType<OkObjectResult>(result);
var settings = await db.PlayerSettings.SingleAsync(x => x.AccountId == Player);
Assert.Equal("2004-02-29", settings.DeserializeSettings().Single(x => x.Key == "birthday").Value);
}
[Fact]
public async Task AnUnknownSlashNamedActionIsStillNotFound()
{
var db = await WithPlayer("developer");
// Rejoining the halves must not turn the route into a catch-all.
var result = await SendAction(db, "roles", "bogus", "");
var notFound = Assert.IsType<NotFoundObjectResult>(result);
Assert.Equal("Unknown player action 'roles/bogus'.", notFound.Value);
}
[Fact]
public async Task ASingleSegmentActionStillReachesItsCase()
{
var db = await WithPlayer("developer");
// The one-segment route and the two-segment one share the same switch; a plain action
// (no slash in its name) still arrives whole.
var api = new AdminPanelAPI(db);
var original = DBConfig.AdminApiKey;
DBConfig.AdminApiKey = Secret;
try
{
var result = await api.PlayerPanelAction(Player, "unban", Secret);
Assert.IsType<OkObjectResult>(result);
}
finally
{
DBConfig.AdminApiKey = original;
}
}
}