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; /// /// 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). /// 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); /// A database holding just the profile a player action needs. private static async Task 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; } /// /// 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. /// private static async Task 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 StoredProfile(RecDBContext db) => await db.Profiles.SingleAsync(x => x.accountId == Player); /// /// 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. /// [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(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(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(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(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(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(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(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(result); } finally { DBConfig.AdminApiKey = original; } } }