Admin interface
This commit is contained in:
+215
@@ -48,6 +48,7 @@ class PersistenceTests(unittest.TestCase):
|
||||
"token_hashes": ["first-session", "second-session"],
|
||||
"created_at": 123,
|
||||
"subscribed": True,
|
||||
"is_admin": False, "is_banned": False, "is_deleted": False,
|
||||
},
|
||||
"bob": {
|
||||
"name": "Bøb",
|
||||
@@ -55,6 +56,7 @@ class PersistenceTests(unittest.TestCase):
|
||||
"token_hashes": [],
|
||||
"created_at": 456,
|
||||
"subscribed": False,
|
||||
"is_admin": False, "is_banned": False, "is_deleted": False,
|
||||
},
|
||||
}
|
||||
state["presses"] = [
|
||||
@@ -67,6 +69,219 @@ class PersistenceTests(unittest.TestCase):
|
||||
def write_legacy(self, state):
|
||||
self.game.DATA_FILE.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
def admin_client(self):
|
||||
request = self.presence_client()
|
||||
self.game.state["players"]["alice"]["is_admin"] = True
|
||||
self.game.save_state(self.game.state)
|
||||
return request
|
||||
|
||||
def test_admin_endpoints_deny_guests_and_regular_players(self):
|
||||
request = self.admin_client()
|
||||
for path in ("/api/admin",):
|
||||
self.assertEqual(request(path=path)[0], 401)
|
||||
self.assertEqual(request(token="bob-token", path=path)[0], 403)
|
||||
for action in ("set_admin", "set_banned", "delete_player", "delete_message"):
|
||||
payload = {"action": action, "target_id": "alice", "value": True, "at": 1}
|
||||
self.assertEqual(request("POST", payload, path="/api/admin/action")[0], 401)
|
||||
self.assertEqual(request("POST", payload, "bob-token", "/api/admin/action")[0], 403)
|
||||
self.assertTrue(self.game.state["players"]["alice"]["is_admin"])
|
||||
|
||||
def test_admin_overview_does_not_expose_credentials_and_role_is_private(self):
|
||||
request = self.admin_client()
|
||||
status, dashboard = request(token="alice-token", path="/api/admin")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(dashboard["actor_id"], "alice")
|
||||
self.assertEqual(len(dashboard["players"]), 2)
|
||||
raw = json.dumps(dashboard)
|
||||
for secret in ("password_hash", "token_hash", "existing-password-hash", "first-session"):
|
||||
self.assertNotIn(secret, raw)
|
||||
self.assertTrue(request(token="alice-token", path="/api/me")[1]["is_admin"])
|
||||
for row in self.game.public_leaderboard() + self.game.global_stats()["leaderboard"]:
|
||||
self.assertNotIn("is_admin", row)
|
||||
|
||||
def test_admin_role_changes_revoke_sessions_and_survive_save_reload(self):
|
||||
request = self.admin_client()
|
||||
payload = {"action": "set_admin", "target_id": "bob", "value": True}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.assertTrue(self.game.state["players"]["bob"]["is_admin"])
|
||||
self.assertEqual(request(token="bob-token", path="/api/admin")[0], 401)
|
||||
self.assertEqual(self.game.state["players"]["bob"]["token_hashes"], [])
|
||||
self.game.save_state(self.game.state)
|
||||
self.game.state = self.game.load_state()
|
||||
self.assertTrue(self.game.state["players"]["bob"]["is_admin"])
|
||||
# Simulate a new authenticated session, then demote it from a different admin.
|
||||
self.game.token_index[self.game.hash_token("bob-new")] = "bob"
|
||||
self.game.state["players"]["bob"]["token_hashes"] = [self.game.hash_token("bob-new")]
|
||||
self.assertEqual(request(token="bob-new", path="/api/admin")[0], 200)
|
||||
self.assertEqual(request("POST", {**payload, "value": False}, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.assertEqual(request(token="bob-new", path="/api/admin")[0], 401)
|
||||
|
||||
def test_ban_revokes_all_access_and_unban_requires_new_login(self):
|
||||
request = self.admin_client()
|
||||
self.game.state["players"]["bob"]["password_hash"] = self.game.hash_password("test-password")
|
||||
self.game.save_state(self.game.state)
|
||||
update = {"tab_id": "a" * 32, "active": True, "in_chat": True}
|
||||
self.assertEqual(request("POST", update, "bob-token")[0], 200)
|
||||
payload = {"action": "set_banned", "target_id": "bob", "value": True}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200)
|
||||
for path, method, data in (("/api/me", "GET", None), ("/api/press", "POST", {}),
|
||||
("/api/chat", "POST", {"message": "Banned"}), ("/api/presence", "POST", update),
|
||||
("/api/subscribe", "POST", {})):
|
||||
self.assertEqual(request(method, data, "bob-token", path)[0], 401)
|
||||
self.assertEqual(request()[1]["online_count"], 0)
|
||||
self.assertEqual(request("POST", {"name": "Bøb", "password": "test-password"}, path="/api/login")[0], 401)
|
||||
self.assertEqual(request("POST", {"action": "set_admin", "target_id": "bob", "value": True}, "alice-token", "/api/admin/action")[0], 409)
|
||||
self.assertEqual(request("POST", {**payload, "value": False}, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.assertFalse(self.game.state["players"]["bob"]["is_banned"])
|
||||
self.assertEqual(request(token="bob-token", path="/api/me")[0], 401)
|
||||
self.assertEqual(request("POST", {"name": "Bøb", "password": "test-password"}, path="/api/login")[0], 200)
|
||||
|
||||
def test_admin_self_protection_and_last_active_admin(self):
|
||||
request = self.admin_client()
|
||||
for action, value in (("set_admin", False), ("set_banned", True), ("delete_player", None)):
|
||||
payload = {"action": action, "target_id": "alice", "value": value}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 409)
|
||||
self.assertEqual(sum(self.game.active_admin(p) for p in self.game.state["players"].values()), 1)
|
||||
self.assertEqual(request("POST", {"action": "set_admin", "target_id": "bob", "value": "true"}, "alice-token", "/api/admin/action")[0], 400)
|
||||
self.assertEqual(request("POST", {"action": "delete_player", "target_id": "missing"}, "alice-token", "/api/admin/action")[0], 404)
|
||||
self.assertEqual(request("POST", {"action": "execute_sql", "target_id": "bob"}, "alice-token", "/api/admin/action")[0], 400)
|
||||
|
||||
def test_admin_deletes_chat_without_id_reuse_or_cooldown_bypass(self):
|
||||
request = self.admin_client()
|
||||
with self.game.database() as connection:
|
||||
at = self.game.now_ms()
|
||||
cursor = connection.execute("INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)",
|
||||
("bob", "Bøb", at, "Remove this"))
|
||||
mid = cursor.lastrowid
|
||||
payload = {"action": "delete_message", "target_id": str(mid), "at": at}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.assertFalse(any(m["id"] == mid for m in self.game.chat_messages()))
|
||||
self.assertEqual(request("POST", {"message": "Too soon"}, "bob-token", "/api/chat")[0], 429)
|
||||
with self.game.database() as connection:
|
||||
self.assertEqual(connection.execute("SELECT message FROM chat_messages WHERE id = ?", (mid,)).fetchone()[0], "[Message removed]")
|
||||
self.game.load_state()
|
||||
self.assertFalse(any(m["id"] == mid for m in self.game.chat_messages()))
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 404)
|
||||
|
||||
def test_admin_can_remove_system_milestone_without_reannouncing_it(self):
|
||||
request = self.admin_client()
|
||||
at = self.game.now_ms()
|
||||
with self.game.database() as connection:
|
||||
connection.execute("UPDATE community_milestones SET reached_at = ?, announced_at = ?, message = ? WHERE id = 'ten_players'", (at, at, "Milestone to remove"))
|
||||
payload = {"action": "delete_message", "target_id": "milestone:ten_players", "at": at}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.game.check_ended()
|
||||
self.assertFalse(any(m["message"] == "Milestone to remove" for m in self.game.chat_messages()))
|
||||
with self.game.database() as connection:
|
||||
self.assertIsNotNone(connection.execute("SELECT reached_at FROM community_milestones WHERE id = 'ten_players'").fetchone()[0])
|
||||
boundary = self.game.current_season(connection)["ends_at"]
|
||||
self.game.check_ended(boundary)
|
||||
self.assertFalse(any(m["at"] == at and m["kind"] == "milestone" for m in self.game.chat_messages()))
|
||||
|
||||
def test_admin_account_deletion_anonymizes_without_losing_game_history(self):
|
||||
request = self.admin_client()
|
||||
before_presses = copy.deepcopy(self.game.state["presses"])
|
||||
with self.game.database() as connection:
|
||||
season = self.game.current_season(connection)
|
||||
self.game.archive_season(connection, season, self.game.state, self.game.state["deadline"], "countdown")
|
||||
connection.execute("INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", ("bob", "Bøb", 123, "My message"))
|
||||
self.assertEqual(request("POST", {"action": "delete_player", "target_id": "bob"}, "alice-token", "/api/admin/action")[0], 200)
|
||||
player = self.game.state["players"]["bob"]
|
||||
self.assertTrue(player["is_deleted"])
|
||||
self.assertEqual(player["name"], "Deleted player")
|
||||
self.assertIsNone(player["password_hash"])
|
||||
self.assertEqual(player["token_hashes"], [])
|
||||
self.assertIsNone(self.game.find_player_by_name("Bøb"))
|
||||
self.assertEqual(self.game.state["presses"], before_presses)
|
||||
self.assertEqual(request(token="bob-token", path="/api/me")[0], 401)
|
||||
self.assertNotIn("bob", [p["id"] for p in request(token="alice-token", path="/api/admin")[1]["players"]])
|
||||
self.assertNotIn("Bøb", json.dumps(self.game.seasonal_leaderboard(), ensure_ascii=False))
|
||||
self.assertTrue(any(m["message"] == "[Account deleted]" for m in self.game.chat_messages()))
|
||||
self.game.state = self.game.load_state()
|
||||
self.assertTrue(self.game.state["players"]["bob"]["is_deleted"])
|
||||
with self.game.database() as connection:
|
||||
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
self.assertEqual(connection.execute("SELECT action, actor_id FROM admin_audit").fetchone()["action"], "delete_player")
|
||||
|
||||
def test_admin_action_rollback_does_not_revoke_or_mutate(self):
|
||||
request = self.admin_client()
|
||||
before = copy.deepcopy(self.game.state)
|
||||
with self.game.database() as connection:
|
||||
connection.execute("""CREATE TRIGGER fail_admin_audit BEFORE INSERT ON admin_audit
|
||||
BEGIN SELECT RAISE(ABORT, 'test rollback'); END""")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.game.admin_action("alice", {"action": "set_banned", "target_id": "bob", "value": True})
|
||||
self.assertEqual(self.game.state, before)
|
||||
self.assertIn(self.game.hash_token("bob-token"), self.game.token_index)
|
||||
self.assertEqual(request(token="bob-token", path="/api/me")[0], 200)
|
||||
with self.game.database() as connection:
|
||||
connection.execute("DROP TRIGGER fail_admin_audit")
|
||||
self.assertEqual(self.game.load_state(), before)
|
||||
|
||||
def test_public_registration_cannot_grant_admin_or_bypass_ban_flags(self):
|
||||
request = self.admin_client()
|
||||
data = {"name": "Newcomer", "password": "test-password", "is_admin": True, "is_banned": False}
|
||||
status, result = request("POST", data, path="/api/register")
|
||||
self.assertEqual(status, 201)
|
||||
pid = self.game.find_player_by_name("Newcomer")
|
||||
self.assertFalse(self.game.state["players"][pid].get("is_admin", False))
|
||||
self.assertEqual(request(token=result["token"], path="/api/admin")[0], 403)
|
||||
|
||||
def test_admin_schema_upgrade_and_database_provisioning(self):
|
||||
self.game.load_state()
|
||||
self.game.state = self.sample_state()
|
||||
self.game.save_state(self.game.state)
|
||||
with self.game.database() as connection:
|
||||
connection.execute("PRAGMA foreign_keys = OFF")
|
||||
connection.execute("""CREATE TABLE old_players (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, password_hash TEXT,
|
||||
created_at INTEGER NOT NULL, subscribed INTEGER NOT NULL
|
||||
)""")
|
||||
connection.execute("INSERT INTO old_players SELECT id, name, password_hash, created_at, subscribed FROM players")
|
||||
connection.execute("DROP TABLE players")
|
||||
connection.execute("ALTER TABLE old_players RENAME TO players")
|
||||
loaded = self.game.load_state()
|
||||
self.assertFalse(any(p["is_admin"] for p in loaded["players"].values()))
|
||||
with self.game.database() as connection:
|
||||
connection.execute("UPDATE players SET is_admin = 1 WHERE id = ?", ("alice",))
|
||||
self.game.state = self.game.load_state()
|
||||
self.assertTrue(self.game.state["players"]["alice"]["is_admin"])
|
||||
self.game.save_state(self.game.state)
|
||||
self.assertTrue(self.game.load_state()["players"]["alice"]["is_admin"])
|
||||
with self.game.database() as connection:
|
||||
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
|
||||
def test_legacy_json_cannot_provision_administrators(self):
|
||||
legacy = self.sample_state()
|
||||
legacy["players"]["alice"].update(is_admin=True, is_banned=True, is_deleted=True)
|
||||
self.write_legacy(legacy)
|
||||
self.game.state = self.game.load_state()
|
||||
for field in ("is_admin", "is_banned", "is_deleted"):
|
||||
self.assertFalse(self.game.state["players"]["alice"][field])
|
||||
self.assertFalse(self.game.load_state()["players"]["alice"][field])
|
||||
|
||||
def test_admin_malformed_request_and_cross_site_mutation(self):
|
||||
request = self.admin_client()
|
||||
self.assertEqual(request("POST", {}, "alice-token", "/api/admin/unknown")[0], 404)
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(thread.join)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
headers = {"Authorization": "Bearer alice-token", "Content-Type": "application/json"}
|
||||
for body, extra, expected in (("{broken", {}, 400), ("[]", {}, 400),
|
||||
(json.dumps({"action": "set_banned", "target_id": "bob", "value": True}), {"Sec-Fetch-Site": "cross-site"}, 403)):
|
||||
connection = HTTPConnection(*httpd.server_address, timeout=5)
|
||||
try:
|
||||
connection.request("POST", "/api/admin/action", body, {**headers, **extra})
|
||||
response = connection.getresponse()
|
||||
self.assertEqual(response.status, expected)
|
||||
response.read()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertFalse(self.game.state["players"]["bob"]["is_banned"])
|
||||
|
||||
def season_fixture(self):
|
||||
at = int(datetime(2026, 10, 21, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
with patch.object(self.game, "now_ms", return_value=at):
|
||||
|
||||
Reference in New Issue
Block a user