Add identity-safe player moderation runtimes for T5 and T6
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
#include "moderation.hpp"
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
using namespace utils::moderation;
|
||||
static const long long NOW = timestamp("2026-10-15T12:00:00.000Z");
|
||||
static const std::string END = "2026-10-15T12:00:30.000Z";
|
||||
static json reply(int revision, json sanctions = json::array(), json actions = json::array())
|
||||
{
|
||||
if (sanctions.is_null()) sanctions = json::array();
|
||||
if (sanctions.is_object()) sanctions = json::array({sanctions});
|
||||
if (actions.is_object()) actions = json::array({actions});
|
||||
return {{"ok", true}, {"revision", revision}, {"serverTime", "2026-10-15T12:00:00.000Z"}, {"sanctions", sanctions}, {"actions", actions}, {"pollAfterMs", 3000}};
|
||||
}
|
||||
static json policy(std::string kind, std::string expiry = END)
|
||||
{
|
||||
return {{"id", "policy"}, {"revision", 1}, {"guid", "18446744073709551615"}, {"kind", kind}, {"reason", "rule"}, {"expiresAt", expiry.empty() ? json(nullptr) : json(expiry)}};
|
||||
}
|
||||
static json act(std::string id, std::string connection, std::string kind = "kick")
|
||||
{
|
||||
return {{"id", id}, {"guid", "18446744073709551615"}, {"connection", connection}, {"kind", kind}, {"reason", "rule; say bad\n\"bad\\^1"}, {"expiresAt", END}};
|
||||
}
|
||||
template<class F> void rejects(F f) { bool threw = false; try { f(); } catch (...) { threw = true; } assert(threw); }
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
assert(argc >= 2); auto root = std::filesystem::path(argv[1]);
|
||||
runtime game(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a");
|
||||
if (argc == 3)
|
||||
{
|
||||
if (std::string(argv[2]) == "write")
|
||||
{
|
||||
assert(game.request()["sessionSequence"] == 1);
|
||||
game.observe(0, "18446744073709551615", "one"); auto id = game.players.at(0).connection;
|
||||
game.accept(reply(1, {policy("ban", "")}, {act("crash", id)}).dump(), NOW);
|
||||
game.enforce(NOW, [](const auto&, int, const auto&) -> std::string { return "unknown"; });
|
||||
}
|
||||
else
|
||||
{
|
||||
assert(game.request()["sessionSequence"] == 2);
|
||||
assert(game.revision() == 1); assert(game.request()["receipts"][0]["id"] == "crash");
|
||||
assert(game.request()["receipts"][0]["state"] == "unknown");
|
||||
int kicks = 0; game.observe(0, "18446744073709551615", "one");
|
||||
game.enforce(NOW + 10, [&](const auto& kind, int, const auto&) { if (kind == "kick") ++kicks; return "confirmed"; });
|
||||
assert(kicks == 1); // cached permanent policy enforces without a network response
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
assert(canonical_guid("000123") == "123" && canonical_guid("000") == "0");
|
||||
assert(canonical_guid("18446744073709551615") == "18446744073709551615" && canonical_guid("abc001") == "abc001");
|
||||
assert(timestamp("2026-10-15T12:00:00Z") == NOW);
|
||||
for (const auto* invalid : {"garbage", "2026-02-31T12:00:00Z", "2026-10-15T12:00:00+01:00", "2026-10-15T12:00:00.abcZ"}) rejects([&] { timestamp(invalid); });
|
||||
assert(command_text("a\";\\\n\r^1b") == "a 1b");
|
||||
assert(command_text(std::string(239, 'a') + "é") == std::string(239, 'a'));
|
||||
assert(command_text(std::string(238, 'a') + "é") == std::string(238, 'a') + "é");
|
||||
game.observe(1, "000123", std::string(254, 'a') + "é");
|
||||
assert(game.players.at(1).guid == "123" && game.players.at(1).name == std::string(254, 'a'));
|
||||
game.observe(1, "123", std::string("bad") + char(0xff)); assert(game.players.at(1).name == "bad?");
|
||||
game.observe(1, "123", std::string("bad") + char(1) + "name"); assert(game.players.at(1).name == "bad name");
|
||||
for (const std::string blank : {"", " ", "\t\r\n\x01\x7f", "\xc2\xa0\xe2\x80\x83\xef\xbb\xbf"})
|
||||
{
|
||||
game.observe(1, "123", blank); assert(game.players.at(1).name == "Unknown player");
|
||||
assert(game.request()["players"][0]["name"] == "Unknown player");
|
||||
}
|
||||
rejects([&] { game.observe(1, "abc.001", "name"); });
|
||||
game.request().dump(); game.disconnect(1);
|
||||
game.observe(0, "18446744073709551615", "same name"); auto first = game.players.at(0).connection;
|
||||
game.observe(0, "18446744073709551615", "new name"); assert(game.players.at(0).connection == first);
|
||||
game.disconnect(0); game.observe(0, "18446744073709551615", "same name"); assert(game.players.at(0).connection != first);
|
||||
int executed = 0;
|
||||
auto execute = [&](const std::string&, int, const std::string& text) { ++executed; assert(text.find_first_of("\r\n;\"\\^") == std::string::npos); return "confirmed"; };
|
||||
game.accept(reply(1, {}, {act("stale", first)}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 0);
|
||||
assert(game.request()["receipts"][0]["state"] == "failed");
|
||||
const auto valid = act("once", game.players.at(0).connection);
|
||||
game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1);
|
||||
game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1);
|
||||
auto changed = valid; changed["reason"] = "different action";
|
||||
rejects([&] { game.accept(reply(1, {}, {changed}).dump(), NOW); });
|
||||
auto expired = act("expired", game.players.at(0).connection); game.accept(reply(1, {}, {expired}).dump(), NOW + 31); game.enforce(NOW + 31, execute); assert(executed == 1);
|
||||
auto future = reply(2); future["serverTime"] = "2026-10-16T12:00:00Z"; rejects([&] { game.accept(future.dump(), NOW); });
|
||||
rejects([&] { game.accept(reply(0).dump(), NOW); });
|
||||
auto invalid = reply(2, {policy("mute")}); invalid["sanctions"][0]["guid"] = "bad guid"; rejects([&] { game.accept(invalid.dump(), NOW); }); assert(game.revision() == 1);
|
||||
for (const auto* malformed : {"{}", "[]", "not json", "{\"ok\":false}"}) rejects([&] { game.accept(malformed, NOW); });
|
||||
auto too_many = reply(2); for (int i = 0; i < 1001; ++i) too_many["sanctions"].push_back(policy("mute")); rejects([&] { game.accept(too_many.dump(), NOW); });
|
||||
rejects([&] { game.accept(std::string(1024 * 1024 + 1, ' '), NOW); });
|
||||
rejects([&] { game.accept(reply(1, {policy("mute")}).dump(), NOW); });
|
||||
std::vector<std::string> effects;
|
||||
auto record = [&](const auto& kind, int, const auto&) { effects.push_back(kind); return "unknown"; };
|
||||
game.accept(reply(2, {policy("mute")}).dump(), NOW); game.enforce(NOW, record); game.enforce(NOW + 1, record); assert(effects == std::vector<std::string>{"mute"});
|
||||
game.enforce(NOW + 31, record); assert(effects == (std::vector<std::string>{"mute", "unmute"}));
|
||||
auto expired_snapshot = reply(2); expired_snapshot["serverTime"] = "2026-10-15T12:00:31.000Z";
|
||||
game.accept(expired_snapshot.dump(), NOW); // server expiry wins even when local clock lags within allowed skew
|
||||
game.accept(reply(3, {policy("mute", "")}).dump(), NOW); game.enforce(NOW, record);
|
||||
game.accept(reply(4).dump(), NOW); game.enforce(NOW, record); assert(effects.back() == "unmute");
|
||||
game.accept(reply(5, {policy("ban", "")}).dump(), NOW);
|
||||
auto disconnecting = [&](const auto& kind, int slot, const auto&) { assert(kind == "kick"); game.disconnect(slot); return "confirmed"; };
|
||||
game.enforce(NOW, disconnecting); assert(game.players.empty());
|
||||
// Slow host time and buffered responses cannot extend a one-time deadline.
|
||||
{
|
||||
runtime slow(root / "slow-clock", "server", "endpoint", 1, "credential-a");
|
||||
slow.observe(0, "18446744073709551615", "one");
|
||||
slow.accept(reply(1, {}, {act("slow", slow.players.at(0).connection)}).dump(), NOW - 60, 31000);
|
||||
const auto count = executed; slow.enforce(NOW - 60, execute); assert(executed == count);
|
||||
assert(slow.request()["receipts"][0]["state"] == "expired");
|
||||
}
|
||||
// A faster next response cannot roll the conservative server-time anchor back.
|
||||
{
|
||||
runtime monotonic(root / "monotonic-clock", "server", "endpoint", 1, "credential-a");
|
||||
monotonic.observe(0, "18446744073709551615", "one");
|
||||
auto timed = reply(1, {policy("mute", "2026-10-15T12:00:07.000Z")});
|
||||
const auto count = effects.size();
|
||||
monotonic.accept(timed.dump(), NOW, 8000); monotonic.enforce(NOW, record);
|
||||
monotonic.accept(timed.dump(), NOW, 0); monotonic.enforce(NOW, record);
|
||||
assert(effects.size() == count);
|
||||
}
|
||||
// A superseded runtime cannot overwrite a newer session counter or execute.
|
||||
{
|
||||
const auto replacement_root = root / "replacement";
|
||||
runtime older(replacement_root, "server", "endpoint", 1, "credential-a");
|
||||
older.observe(0, "18446744073709551615", "one");
|
||||
older.accept(reply(1, {}, {act("superseded", older.players.at(0).connection)}).dump(), NOW);
|
||||
runtime newer(replacement_root, "server", "endpoint", 1, "credential-a");
|
||||
const auto count = executed; rejects([&] { older.enforce(NOW, execute); }); assert(executed == count);
|
||||
newer.accept(reply(2).dump(), NOW); assert(newer.request()["sessionSequence"] == 2);
|
||||
runtime recovered(replacement_root, "server", "endpoint", 1, "credential-a");
|
||||
assert(recovered.request()["sessionSequence"] == 3 && recovered.revision() == 2);
|
||||
for (const auto& file : std::filesystem::directory_iterator(replacement_root)) std::filesystem::remove(file.path());
|
||||
rejects([&] { recovered.accept(reply(3).dump(), NOW); }); // cache deletion cannot silently reset its sequence
|
||||
}
|
||||
// Managed mute ownership survives restart and allows revocation without clearing unrelated mutes.
|
||||
const auto mute_root = root / "mute-restart";
|
||||
{
|
||||
runtime original(mute_root, "server", "endpoint", 1, "credential-a");
|
||||
original.observe(0, "18446744073709551615", "one");
|
||||
original.accept(reply(1, {policy("mute", "")}).dump(), NOW);
|
||||
original.enforce(NOW, record);
|
||||
}
|
||||
{
|
||||
runtime recovered(mute_root, "server", "endpoint", 1, "credential-a");
|
||||
recovered.accept(reply(2).dump(), NOW);
|
||||
recovered.observe(1, "18446744073709551615", "one");
|
||||
const auto before = effects.size(); recovered.enforce(NOW, record);
|
||||
assert(effects.size() == before + 1 && effects.back() == "unmute");
|
||||
recovered.observe(2, "someone-else", "two"); recovered.enforce(NOW, record);
|
||||
assert(effects.size() == before + 1);
|
||||
}
|
||||
// Pre-dispatch disk failure must prevent the irreversible effect.
|
||||
game.observe(0, "18446744073709551615", "one");
|
||||
game.accept(reply(6, {}, {act("disk", game.players.at(0).connection)}).dump(), NOW);
|
||||
std::filesystem::path journal;
|
||||
for (const auto& file : std::filesystem::directory_iterator(root)) if (file.path().extension() == ".json") journal = file.path();
|
||||
auto temporary = journal; temporary += ".tmp"; std::filesystem::create_directory(temporary);
|
||||
const auto before_disk = executed; rejects([&] { game.enforce(NOW, execute); }); assert(executed == before_disk);
|
||||
std::filesystem::remove(temporary);
|
||||
runtime different_credential(root, "server", "https://controller/api/moderation/runtime", 1, "credential-b");
|
||||
assert(different_credential.revision() == 0 && different_credential.request()["receipts"].empty());
|
||||
// Corruption is visible: runtime never replaces a bad persistent cache with empty policy.
|
||||
{ std::ofstream file(journal); file << "corrupt"; }
|
||||
rejects([&] { runtime broken(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a"); });
|
||||
std::cout << "moderation policy, identity, expiry, revocation, journal and schema checks passed\n";
|
||||
}
|
||||
Reference in New Issue
Block a user