"""Persistence and migration regression tests; never use the live game files.""" import copy import importlib.util import json import os import sqlite3 import tempfile import threading import unittest from http.client import HTTPConnection from http.server import ThreadingHTTPServer from pathlib import Path from unittest.mock import patch class PersistenceTests(unittest.TestCase): @classmethod def setUpClass(cls): with tempfile.TemporaryDirectory() as directory: with patch.dict(os.environ, { "BUTTON_DATA_FILE": str(Path(directory) / "data.json"), "BUTTON_DATABASE_FILE": str(Path(directory) / "data.sqlite3"), "BUTTON_MAILING_LIST_FILE": str(Path(directory) / "mailing.csv"), }): spec = importlib.util.spec_from_file_location("button_test_server", Path(__file__).with_name("server.py")) cls.game = importlib.util.module_from_spec(spec) spec.loader.exec_module(cls.game) def setUp(self): self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) root = Path(self.directory.name) self.game.DATA_FILE = root / "data.json" self.game.DATABASE_FILE = root / "data.sqlite3" self.game.MAILING_LIST_FILE = root / "mailing.csv" self.game.failed_logins.clear() self.game.subscribed_emails.clear() self.game.presence.clear() def sample_state(self): state = self.game.new_game() state["players"] = { "alice": { "name": "Alice '); DROP TABLE players; --", "password_hash": "existing-password-hash", "token_hashes": ["first-session", "second-session"], "created_at": 123, "subscribed": True, }, "bob": { "name": "Bøb", "password_hash": None, "token_hashes": [], "created_at": 456, "subscribed": False, }, } state["presses"] = [ {"player_id": "alice", "at": 100, "remaining_ms": 20, "zone": "white", "points": 32}, {"player_id": "bob", "at": 100, "remaining_ms": 50, "zone": "black", "points": 16}, {"player_id": "alice", "at": 200, "remaining_ms": 90, "zone": "red", "points": 8}, ] return state def write_legacy(self, state): self.game.DATA_FILE.write_text(json.dumps(state), encoding="utf-8") def presence_client(self): self.game.load_state() self.game.state = self.sample_state() self.game.state["players"]["alice"]["token_hashes"] = [self.game.hash_token("alice-token"), self.game.hash_token("alice-second")] self.game.state["players"]["bob"]["token_hashes"] = [self.game.hash_token("bob-token")] self.game.save_state(self.game.state) self.game.token_index = { th: pid for pid, player in self.game.state["players"].items() for th in player["token_hashes"] } 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) def request(method="GET", data=None, token=None, path="/api/presence"): connection = HTTPConnection(*httpd.server_address, timeout=5) try: headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = "Bearer " + token connection.request(method, path, json.dumps(data) if data is not None else None, headers) response = connection.getresponse() return response.status, json.loads(response.read()) finally: connection.close() return request def test_presence_auth_validation_and_default_privacy(self): request = self.presence_client() self.assertEqual(request(), (200, {"online_count": 0, "chat_players": []})) update = {"tab_id": "a" * 32, "active": True, "in_chat": True} self.assertEqual(request("POST", update)[0], 401) self.assertEqual(request("POST", update, "not-a-token")[0], 401) for invalid in ({}, {**update, "active": 1}, {**update, "in_chat": "true"}, {**update, "tab_id": "a" * 33}, {**update, "tab_id": "