"""Disposable, offline Raiju fixtures; never copy live config or content.""" import http.client import json import os from pathlib import Path import secrets import shutil import socket import subprocess import time import urllib.parse from http.cookies import SimpleCookie ROOT = Path(__file__).resolve().parent.parent def copy_core(target): target = Path(target) for folder in ("admin", "api", "includes", "cli", "css", "js", "templates", "examples", "tests"): if (ROOT / folder).is_dir(): shutil.copytree(ROOT / folder, target / folder, ignore=shutil.ignore_patterns("config.php", "smtp-config.php", "nav-config.php", "__pycache__", "*.pyc", "node_modules")) for source in ROOT.iterdir(): if source.is_file() and (source.suffix in (".php", ".md") or source.name in (".htaccess", "VERSION")): shutil.copyfile(source, target / source.name) for folder in ("content/pages", "content/news", "content/forms", "content/pages-trash", "backups", "assets/images/generated", "assets/images-trash", "assets/audio", "assets/audio-trash", "assets/videos", "assets/videos-trash", "assets/documents", "assets/documents-trash"): (target / folder).mkdir(parents=True, exist_ok=True) for name in ("content/menus.json", "content/schema.json", "content/forms/contact.json", "content/.htaccess", "assets/images/favicon.svg", "assets/images/placeholder-image.webp"): shutil.copyfile(ROOT / name, target / name) return target def write_json(path, value): Path(path).write_text(json.dumps(value), encoding="utf-8") class Site: def __init__(self, root): self.root = copy_core(root) self.password = "Review-Aa7!" + secrets.token_urlsafe(18) password_hash = subprocess.check_output( ["php", "-r", "echo password_hash(getenv('REVIEW_PASSWORD'), PASSWORD_DEFAULT);"], env=dict(os.environ, REVIEW_PASSWORD=self.password), text=True) self.users = [{"id": "u_" + role, "username": role, "email": role + "@example.invalid", "role": role, "passwordHash": password_hash, "createdAt": "2026-09-04T00:00:00Z", "lastLogin": None, "resetToken": None, "resetTokenExpiry": None} for role in ("admin", "editor")] config = (self.root / "admin/config.example.php").read_text().replace( "define('RAIJU_DEV_LOGIN', true)", "define('RAIJU_DEV_LOGIN', false)") (self.root / "admin/config.php").write_text(config) write_json(self.root / "content/users.json", {"users": self.users}) write_json(self.root / "content/settings.json", {"modules": {"ai": False}, "privacy": {"analyticsEnabled": False}}) for name in ("home", "services__test"): write_json(self.root / ("content/pages/en_" + name + ".json"), { "page": "en_" + name, "lang": "en", "title": name, "sections": [ {"id": "intro", "type": "text", "content": "

Public fixture

"}]}) self.port = 0 self.process = None def start(self, router="router.php", ini=None): """ini: extra php.ini overrides, e.g. {"upload_max_filesize": "1M"}.""" with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) self.port = sock.getsockname()[1] self.log = open(self.root / "server.log", "w+") flags = ["-d", "display_errors=0"] for key, value in (ini or {}).items(): flags += ["-d", f"{key}={value}"] self.process = subprocess.Popen( ["php", *flags, "-S", f"127.0.0.1:{self.port}", router], cwd=self.root, stdout=self.log, stderr=self.log) for _ in range(100): if self.process.poll() is not None: self.log.seek(0) raise RuntimeError(self.log.read()) try: with socket.create_connection(("127.0.0.1", self.port), timeout=.1): return self except OSError: time.sleep(.05) raise RuntimeError("Test server did not start") def close(self): if self.process: self.process.terminate() self.process.wait(timeout=10) self.log.close() class Client: def __init__(self, site): self.site = site self.cookies = SimpleCookie() self.csrf = "" def request(self, path, data=None, method=None, headers=None): headers = dict(headers or {}) if self.cookies: headers["Cookie"] = "; ".join(f"{k}={v.value}" for k, v in self.cookies.items()) if isinstance(data, dict): data = urllib.parse.urlencode(data) headers["Content-Type"] = "application/x-www-form-urlencoded" connection = http.client.HTTPConnection("127.0.0.1", self.site.port, timeout=15) connection.request(method or ("POST" if data is not None else "GET"), path, body=data, headers=headers) response = connection.getresponse() body = response.read() result_headers = dict(response.getheaders()) for key, value in response.getheaders(): if key.lower() == "set-cookie": self.cookies.load(value) status = response.status connection.close() return status, result_headers, body def login(self, username="admin", password=None): import re self.request("/admin/index.php") old_id = self.cookies["PHPSESSID"].value status, _, _ = self.request("/admin/index.php", {"username": username, "password": password or self.site.password}) assert status == 302, f"Login failed: {status}" assert self.cookies["PHPSESSID"].value != old_id, "Login did not rotate the session ID" status, _, body = self.request("/admin/dashboard.php") assert status == 200, f"Dashboard failed: {status}" self.csrf = re.search(rb"const CSRF_TOKEN = ['\"]([^'\"]+)", body)[1].decode() return self def api(self, action, **fields): status, _, body = self.request("/admin/api.php", {"action": action, "csrf_token": self.csrf, **fields}) try: return status, json.loads(body) except ValueError: raise AssertionError(f"{action}: invalid JSON ({status}): {body[:250]!r}")