Raiju/tests/support.py
2026-10-25 06:05:42 +03:00

136 lines
6.3 KiB
Python

"""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": "<p>Public fixture</p>"}]})
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}")