Files
DarkflameServer/tests/dGameTests/SlashCommandPermissionTests.cpp
Aaron Kimbrell e00d22152e feat(game): slash commands accept permission grants
A command may be used when the character's GM level allows it or a grant does: a grant of the command, of every
command up to a GM level, or of the dashboard permission the command follows (so accounts_kick covers /kick). A deny
of any of those takes it away even when the level allows it, except from GM 9 accounts (also while they play at a
lower level). Grants never take anyone below a command's floor above GM 1 (/execute), and commands the client handles
keep their fixed level. Expired grants count for nothing. The grants are the account's and the logged-in
character's, read from the database the first time a command is used and kept on the User. /help lists the commands
a player may use this way. The self and rank rules for commands that act on another player count grants of self_* and
manage_equal_rank too. A denied command says it was taken away.

Check: grant a GM 0 account /spawn (or "every command up to GM 8") on the dashboard, relog, and /spawn works and
shows in /help; deny /spawn from a GM 8 character: "it was taken away"; grant accounts_kick and /kick works (the rank
rules still apply to whom). dGameTests SlashCommandGrantsTest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:16:49 -05:00

384 lines
19 KiB
C++

#include <gtest/gtest.h>
#include "SlashCommandHandler.h"
#include "Permissions.h"
#include "Game.h"
#include "dConfig.h"
namespace {
// Game::config set to a config with these database values for the length of a test
class ScopedConfig {
public:
explicit ScopedConfig(std::map<std::string, std::string> overrides) : m_Previous(Game::config), m_Config("does_not_exist_config.ini") {
m_Config.SetDatabaseValues(std::move(overrides), {});
Game::config = &m_Config;
}
~ScopedConfig() { Game::config = m_Previous; }
private:
dConfig* m_Previous;
dConfig m_Config;
};
Command Paired(const std::string& name, eGameMasterLevel level, const std::string& permission) {
Command command{ .requiredLevel = level, .minLevel = eGameMasterLevel::FORUM_MODERATOR, .dashboardPermission = permission };
command.name = name;
return command;
}
}
// A paired command needs its permission's level, follows it when it changes, and keeps an explicit override
TEST(SlashCommandPermissionTests, PairedCommandUsesThePermissionLevel) {
const auto mute = Paired("mute", eGameMasterLevel::JUNIOR_DEVELOPER, "accounts_mute");
{
ScopedConfig config({});
EXPECT_EQ(static_cast<uint8_t>(SlashCommandHandler::GetRequiredLevel(mute)), Permissions::Find("accounts_mute")->defaultLevel);
}
{
ScopedConfig config({ { "permission_accounts_mute", "5" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(mute), eGameMasterLevel::LEAD_MODERATOR);
}
{
// An older command_level_mute stays in force
ScopedConfig config({ { "permission_accounts_mute", "5" }, { "command_level_mute", "6" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(mute), eGameMasterLevel::JUNIOR_DEVELOPER);
}
{
// ...until the page drops it
ScopedConfig config({ { "permission_accounts_mute", "5" }, { "command_level_mute", "permission" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(mute), eGameMasterLevel::LEAD_MODERATOR);
}
// An unknown permission: an ordinary command with its own level
const auto unknown = Paired("zz_unknown", eGameMasterLevel::MODERATOR, "no_such_permission");
ScopedConfig config({ { "command_level_zz_unknown", "4" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(unknown), eGameMasterLevel::SENIOR_MODERATOR);
}
TEST(SlashCommandPermissionTests, FloorsApplyToPairedCommands) {
Command command{ .requiredLevel = eGameMasterLevel::DEVELOPER, .minLevel = eGameMasterLevel::DEVELOPER, .dashboardPermission = "accounts_kick" };
command.name = "zz_floor";
ScopedConfig config({ { "permission_accounts_kick", "2" }, { "command_level_zz_floor", "3" } });
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(command), eGameMasterLevel::DEVELOPER);
Command fixed{ .requiredLevel = eGameMasterLevel::CIVILIAN, .fixedLevel = true, .dashboardPermission = "accounts_kick" };
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(fixed), eGameMasterLevel::CIVILIAN);
}
// The dashboard's self and rank rules, as the commands apply them
TEST(SlashCommandPermissionTests, TargetRulesMatchTheDashboard) {
using SlashCommandLevels::eTargetRule;
using AccountRules::eManageDenial;
ScopedConfig config({});
// Nobody below GM 9 acts on a higher level; GM 9 on anyone
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 9, 2, eTargetRule::TOOLS), eManageDenial::HIGHER_RANK);
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 9, 2, eTargetRule::OTHERS), eManageDenial::HIGHER_RANK);
EXPECT_EQ(SlashCommandHandler::TargetDenial(9, 1, 9, 2, eTargetRule::MODERATION), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(9, 1, 9, 1, eTargetRule::MODERATION), eManageDenial::NONE);
// Same level: manage_equal_rank (GM 9 by default)
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 2, eTargetRule::MODERATION), eManageDenial::EQUAL_RANK);
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 3, 2, eTargetRule::MODERATION), eManageDenial::NONE);
// Themselves: the self permission for the kind (self_tools GM 1, self_items and self_moderation GM 9)
EXPECT_EQ(SlashCommandHandler::TargetDenial(2, 1, 2, 1, eTargetRule::TOOLS), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::ITEMS), eManageDenial::SELF);
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::MODERATION), eManageDenial::SELF);
// OTHERS: on themselves as before (like /setlevel without a name)
EXPECT_EQ(SlashCommandHandler::TargetDenial(6, 1, 6, 1, eTargetRule::OTHERS), eManageDenial::NONE);
// NONE: not checked
EXPECT_EQ(SlashCommandHandler::TargetDenial(1, 1, 9, 2, eTargetRule::NONE), eManageDenial::NONE);
// The refusal names the permission
const auto refusal = SlashCommandHandler::TargetRefusal(eManageDenial::SELF, eTargetRule::ITEMS, "mailitem");
EXPECT_EQ(refusal.rfind("/mailitem: ", 0), 0u);
EXPECT_NE(refusal.find("self_items"), std::string::npos);
EXPECT_NE(SlashCommandHandler::TargetRefusal(eManageDenial::EQUAL_RANK, eTargetRule::MODERATION, "ban").find("manage_equal_rank"), std::string::npos);
EXPECT_EQ(SlashCommandHandler::TargetRefusal(eManageDenial::NONE, eTargetRule::TOOLS, "kick"), "");
}
TEST(SlashCommandPermissionTests, TargetRulesFollowThePermissionsPage) {
using SlashCommandLevels::eTargetRule;
using AccountRules::eManageDenial;
ScopedConfig config({ { "permission_self_moderation", "4" }, { "permission_manage_equal_rank", "4" } });
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 1, eTargetRule::MODERATION), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 2, eTargetRule::MODERATION), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(3, 1, 3, 2, eTargetRule::MODERATION), eManageDenial::EQUAL_RANK);
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 5, 2, eTargetRule::MODERATION), eManageDenial::HIGHER_RANK);
}
#include "GameDependencies.h"
class SlashCommandCatalogTest : public GameDependenciesTest {
protected:
void SetUp() override {
SetUpDependencies();
static bool started = false;
if (!started) SlashCommandHandler::Startup();
started = true;
}
void TearDown() override { TearDownDependencies(); }
};
// Which commands share a dashboard permission's level, and which rule they follow on other players
TEST_F(SlashCommandCatalogTest, PairsAndTargetRules) {
using SlashCommandLevels::eTargetRule;
const std::map<std::string, std::string> pairs{
{ "kick", "accounts_kick" }, { "mute", "accounts_mute" }, { "ban", "accounts_ban" },
{ "announce", "server_announce" }, { "setanntitle", "server_announce" }, { "setannmsg", "server_announce" },
{ "mailitem", "mail_items" }, { "approveproperty", "moderate_properties" },
{ "showall", "players_view" }, { "findplayer", "players_view" }, { "spectate", "players_view" },
{ "shutdown", "worlds_manage" }, { "shutdownuniverse", "server_restart" },
{ "uptime", "health_view" }, { "metrics", "health_view" },
// Only on the user's own character, or a movement tool: their own level (see SlashCommandHandler.cpp)
{ "teleport", "" }, { "gmadditem", "" }, { "givemoney", "" }, { "setcurrency", "" }, { "giveuscore", "" }, { "setlevel", "" },
{ "execute", "" }, { "setgmlevel", "" },
};
for (const auto& [alias, permission] : pairs) {
const auto* command = SlashCommandHandler::FindCommand(alias);
ASSERT_NE(command, nullptr) << alias;
EXPECT_EQ(command->dashboardPermission, permission) << alias;
if (!permission.empty()) {
ASSERT_NE(Permissions::Find(permission), nullptr) << permission;
EXPECT_FALSE(command->fixedLevel) << alias;
}
}
const std::map<std::string, eTargetRule> rules{
{ "kick", eTargetRule::TOOLS }, { "kill", eTargetRule::TOOLS }, { "mailitem", eTargetRule::ITEMS },
{ "ban", eTargetRule::MODERATION }, { "mute", eTargetRule::MODERATION },
{ "teleport", eTargetRule::OTHERS }, { "setlevel", eTargetRule::OTHERS }, { "execute", eTargetRule::OTHERS }, { "tpall", eTargetRule::OTHERS },
{ "gmadditem", eTargetRule::NONE }, { "spectate", eTargetRule::NONE }, { "setgmlevel", eTargetRule::NONE },
};
for (const auto& [alias, rule] : rules) {
const auto* command = SlashCommandHandler::FindCommand(alias);
ASSERT_NE(command, nullptr) << alias;
EXPECT_EQ(command->targetRule, rule) << alias;
}
// /execute keeps its floor even though the self and rank rules now apply to it too
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("execute")), eGameMasterLevel::DEVELOPER);
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("setgmlevel")), eGameMasterLevel::CIVILIAN);
}
// When commands start using their permission's level, a server that had them keeps each one's old level once
TEST(SlashCommandPermissionTests, UpgradeOverrideDecision) {
using SlashCommandLevels::UpgradeOverride;
// Existing server, code level differs from the permission's: keep it (/mute: GM 6, accounts_mute GM 2)
EXPECT_EQ(UpgradeOverride(true, false, false, 6, 1, "", 2), std::optional<uint8_t>(6));
// Same level: nothing to keep
EXPECT_EQ(UpgradeOverride(true, false, false, 2, 1, "", 2), std::nullopt);
// Brand-new server (no stored row), or the upgrade already happened: none
EXPECT_EQ(UpgradeOverride(false, false, false, 6, 1, "", 2), std::nullopt);
EXPECT_EQ(UpgradeOverride(true, true, false, 6, 1, "", 2), std::nullopt);
// An explicit value is already an override; "permission" means follow it
EXPECT_EQ(UpgradeOverride(true, false, false, 6, 1, "4", 2), std::nullopt);
EXPECT_EQ(UpgradeOverride(true, false, false, 6, 1, "permission", 2), std::nullopt);
// A bad value meant the code's level
EXPECT_EQ(UpgradeOverride(true, false, false, 6, 1, "junk", 2), std::optional<uint8_t>(6));
EXPECT_EQ(UpgradeOverride(true, false, true, 6, 1, "", 2), std::nullopt);
}
namespace {
// Just the tables ReportCommands uses
class UpgradeDatabase : public TestSQLDatabase {
public:
std::map<std::string, SlashCommand> commands;
std::map<std::string, Setting> config; // worldconfig.ini, by name
std::vector<std::string> audits;
std::vector<SlashCommand> GetSlashCommands() override {
std::vector<SlashCommand> rows;
for (const auto& [_, row] : commands) rows.push_back(row);
return rows;
}
void SetSlashCommand(const SlashCommand& command) override { commands[command.name] = command; }
std::vector<Setting> GetServerConfig(const std::vector<std::string>&) override {
std::vector<Setting> rows;
for (const auto& [_, row] : config) rows.push_back(row);
return rows;
}
void SetWebConfigValue(const std::string& file, const std::string& name, const std::optional<std::string>& value, bool webWins, const std::string& by) override {
auto& row = config[name];
row.file = file;
row.name = name;
row.webValue = value;
row.webWins = webWins;
row.updatedBy = by;
}
void InsertAuditLog(uint32_t, const std::string_view accountName, const std::string_view action, const std::string_view description, uint32_t, LWOOBJID) override {
audits.push_back(std::string(accountName) + " " + std::string(action) + " " + std::string(description));
}
// What a world's settings sync would hand it
std::map<std::string, std::string> WebValues() const {
std::map<std::string, std::string> values;
for (const auto& [name, row] : config) if (row.webValue) values[name] = *row.webValue;
return values;
}
};
ISlashCommands::SlashCommand OldRow(const std::string& name, uint8_t level, const std::string& permission) {
return { .name = name, .aliases = { name }, .defaultLevel = level, .minLevel = 1, .dashboardPermission = permission };
}
}
TEST_F(SlashCommandCatalogTest, ExistingServerKeepsItsLevelsOnce) {
auto* db = new UpgradeDatabase();
Database::_setDatabase(db);
// Rows written before commands followed their permission
db->commands["mute"] = OldRow("mute", 6, "accounts_mute");
db->commands["kick"] = OldRow("kick", 2, "accounts_kick");
db->commands["mailitem"] = OldRow("mailitem", 3, "mail_items");
db->commands["spectate"] = OldRow("spectate", 2, "");
db->commands["ban"] = OldRow("ban", 4, "accounts_ban");
db->config["command_level_ban"] = { .file = "worldconfig.ini", .name = "command_level_ban", .webValue = "3", .webWins = true, .updatedBy = "admin" };
SlashCommandHandler::ReportCommands();
const std::string upgrade(SlashCommandLevels::UPGRADE_ACTOR);
EXPECT_EQ(db->config.at("command_level_mute").webValue, std::optional<std::string>("6"));
EXPECT_EQ(db->config.at("command_level_mute").updatedBy, upgrade);
EXPECT_EQ(db->config.at("command_level_mailitem").webValue, std::optional<std::string>("3"));
EXPECT_EQ(db->config.at("command_level_spectate").webValue, std::optional<std::string>("2"));
EXPECT_FALSE(db->config.contains("command_level_kick")); // same level as accounts_kick
EXPECT_EQ(db->config.at("command_level_ban").updatedBy, "admin"); // its own value was already an override
EXPECT_FALSE(db->config.contains("command_level_announce")); // not stored before: follows
EXPECT_EQ(db->audits.size(), 3u);
for (const auto& audit : db->audits) EXPECT_EQ(audit.rfind(upgrade + " change_command_level ", 0), 0u) << audit;
EXPECT_TRUE(db->commands.at("mute").followsPermission);
// The world uses the kept level
Game::config->SetDatabaseValues(db->WebValues(), {});
EXPECT_EQ(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("mute")), eGameMasterLevel::JUNIOR_DEVELOPER);
// Only once: another start stores nothing new
SlashCommandHandler::ReportCommands();
EXPECT_EQ(db->audits.size(), 3u);
// Dropped on the Permissions page: it follows accounts_mute, and a later start doesn't bring it back
db->SetWebConfigValue("worldconfig.ini", "command_level_mute", std::nullopt, false, "admin");
Game::config->SetDatabaseValues(db->WebValues(), {});
EXPECT_EQ(static_cast<uint8_t>(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("mute"))), Permissions::Level("accounts_mute"));
SlashCommandHandler::ReportCommands();
EXPECT_FALSE(db->config.at("command_level_mute").webValue.has_value());
EXPECT_EQ(db->audits.size(), 3u);
}
TEST_F(SlashCommandCatalogTest, NewServerFollowsThePermissions) {
auto* db = new UpgradeDatabase();
Database::_setDatabase(db);
SlashCommandHandler::ReportCommands();
EXPECT_TRUE(db->config.empty());
EXPECT_TRUE(db->audits.empty());
EXPECT_TRUE(db->commands.at("mute").followsPermission);
EXPECT_FALSE(db->commands.at("gmadditem").followsPermission);
Game::config->SetDatabaseValues({}, {});
EXPECT_EQ(static_cast<uint8_t>(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("mute"))), Permissions::Level("accounts_mute"));
}
#include "Character.h"
#include "Entity.h"
#include "PermissionGrants.h"
#include "User.h"
// Grants and denies from the dashboard (PermissionGrants.h), as a player using commands sees them
class SlashCommandGrantsTest : public GameDependenciesTest {
protected:
static constexpr LWOOBJID CHARACTER_ID = 42;
void SetUp() override {
SetUpDependencies();
static bool started = false;
if (!started) SlashCommandHandler::Startup();
started = true;
user = std::make_unique<User>(UNASSIGNED_SYSTEM_ADDRESS, "tester", "key");
character = std::make_unique<Character>(CHARACTER_ID, user.get());
entity = std::make_unique<Entity>(1, info);
entity->SetCharacter(character.get());
character->SetEntity(entity.get());
}
void TearDown() override {
entity->SetCharacter(nullptr);
entity.reset();
character.reset();
user.reset();
TearDownDependencies();
}
// Play at a GM level (the account's is at least that) with these grants loaded for the character
void Play(eGameMasterLevel level, std::vector<PermissionGrants::Rule> rules, eGameMasterLevel accountLevel = eGameMasterLevel::CIVILIAN) {
user->SetMaxGMLevel(std::max(level, accountLevel));
entity->SetGMLevel(level);
auto held = std::make_shared<PermissionGrants::Held>();
held->rules = std::move(rules);
user->SetGrants(held, CHARACTER_ID);
}
bool MayUse(const std::string& alias) {
const auto* command = SlashCommandHandler::FindCommand(alias);
EXPECT_NE(command, nullptr) << alias;
return command && SlashCommandHandler::MayUse(entity.get(), *command);
}
std::unique_ptr<User> user;
std::unique_ptr<Character> character;
std::unique_ptr<Entity> entity;
};
TEST_F(SlashCommandGrantsTest, GrantsLetAPlayerUseACommand) {
using PermissionGrants::eKind;
Play(eGameMasterLevel::MODERATOR, {});
EXPECT_FALSE(MayUse("spawn"));
EXPECT_TRUE(MayUse("kick")); // accounts_kick: GM 2
Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 0 } });
EXPECT_TRUE(MayUse("spawn"));
EXPECT_FALSE(MayUse("gmadditem"));
// A grant of a dashboard permission covers the commands that follow it
Play(eGameMasterLevel::CIVILIAN, { { eKind::PERMISSION, "accounts_kick", false, 0 } });
EXPECT_TRUE(MayUse("kick"));
// Every command up to a GM level
Play(eGameMasterLevel::CIVILIAN, { { eKind::COMMAND_GROUP, "8", false, 0 } });
EXPECT_TRUE(MayUse("spawn"));
EXPECT_TRUE(MayUse("gmadditem"));
// ...but never /execute below its floor
Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND_GROUP, "9", false, 0 }, { eKind::COMMAND, "execute", false, 0 } });
EXPECT_FALSE(MayUse("execute"));
// Past its expiry a grant does nothing
Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 1 } });
EXPECT_FALSE(MayUse("spawn"));
}
TEST_F(SlashCommandGrantsTest, DeniesTakeCommandsAwayExceptFromOperators) {
using PermissionGrants::eKind;
Play(eGameMasterLevel::DEVELOPER, { { eKind::COMMAND, "spawn", true, 0 }, { eKind::PERMISSION, "accounts_kick", true, 0 } });
EXPECT_FALSE(MayUse("spawn"));
EXPECT_FALSE(MayUse("kick"));
EXPECT_TRUE(MayUse("gmadditem"));
// A GM 9 account playing at GM 8 keeps everything
Play(eGameMasterLevel::DEVELOPER, { { eKind::COMMAND, "spawn", true, 0 } }, eGameMasterLevel::OPERATOR);
EXPECT_TRUE(MayUse("spawn"));
// A player command can be taken from a player
Play(eGameMasterLevel::CIVILIAN, { { eKind::COMMAND, "pvp", true, 0 } });
EXPECT_FALSE(MayUse("pvp"));
}
TEST_F(SlashCommandGrantsTest, ForgottenGrantsAreReadAgain) {
using PermissionGrants::eKind;
Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 0 } });
EXPECT_TRUE(MayUse("spawn"));
// The dashboard changed them: read from the database again (none there)
user->ForgetGrants();
EXPECT_FALSE(MayUse("spawn"));
ASSERT_NE(user->GetGrants(), nullptr);
EXPECT_EQ(user->GetGrantsCharacter(), CHARACTER_ID);
}
TEST(SlashCommandPermissionTests, TargetRulesFollowGrants) {
using SlashCommandLevels::eTargetRule;
using AccountRules::eManageDenial;
using PermissionGrants::eKind;
ScopedConfig config({});
PermissionGrants::Held held;
held.rules = { { eKind::PERMISSION, "self_moderation", false, 0 }, { eKind::PERMISSION, "manage_equal_rank", false, 0 } };
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::MODERATION), eManageDenial::SELF);
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::MODERATION, &held), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 2, eTargetRule::MODERATION, &held), eManageDenial::NONE);
// Never a higher GM level
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 5, 2, eTargetRule::MODERATION, &held), eManageDenial::HIGHER_RANK);
}