"""Frame Control server checks that need no headset. Starts ui/server.py against an SSH alias that can't resolve, then exercises the request guards and input validation, which all run before any SSH call. Run: python3 -m unittest discover -s tests """ import sandbox # noqa: F401 (first: keeps tests off real data and services) import http.client import io import json import os import socket import struct import subprocess import sys import tempfile import time import unittest import zipfile from pathlib import Path from urllib.parse import quote ROOT = Path(__file__).resolve().parent.parent def free_port(): with socket.socket() as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] class ServerGuards(unittest.TestCase): @classmethod def setUpClass(cls): cls.port = free_port() cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": cls.ssh_dir} cls.log = tempfile.TemporaryFile() cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)], env=env, stdout=cls.log, stderr=subprocess.STDOUT) for _ in range(100): try: if cls.request("GET", "/")[0] == 200: return except Exception: pass time.sleep(0.05) cls.proc.kill() cls.log.seek(0) raise RuntimeError("server didn't start:\n" + cls.log.read().decode(errors="replace")) @classmethod def tearDownClass(cls): cls.proc.terminate() cls.proc.wait(timeout=10) cls.log.close() @classmethod def request(cls, method, path, body=None, headers=None): conn = http.client.HTTPConnection("127.0.0.1", cls.port, timeout=10) data = body if isinstance(body, bytes) else json.dumps(body).encode() if body is not None else None conn.request(method, path, body=data, headers=headers or {}) r = conn.getresponse() payload = r.read() conn.close() return r.status, dict(r.getheaders()), payload def post(self, path, body): status, _, payload = self.request("POST", path, body, {"X-Frame-UI": "1", "Content-Type": "application/json"}) return status, json.loads(payload) def test_page_served_with_identifying_and_anti_framing_headers(self): status, headers, payload = self.request("GET", "/") self.assertEqual(status, 200) self.assertTrue(headers["Server"].startswith("FrameControl")) self.assertEqual(headers["X-Frame-Options"], "DENY") self.assertIn(b" and plain form posts from other sites can't set it. self.assertEqual(self.request("POST", "/api/comfort", {"action": "start"})[0], 403) self.assertEqual(self.request("GET", "/api/status")[0], 403) self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403) self.assertEqual(self.request("GET", "/api/shots")[0], 403) self.assertEqual(self.request("GET", "/api/stream")[0], 403) self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403) self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403) def test_captures_are_not_cacheable(self): # Headset captures show everything on screen; nothing may cache them. _, headers, _ = self.request("GET", "/api/screenshot", headers={"X-Frame-UI": "1"}) self.assertEqual(headers.get("Cache-Control"), "no-store") self.assertIn("frame-ancestors 'none'", headers.get("Content-Security-Policy", "")) def test_input_validation(self): cases = [ ("/api/comfort", {"action": "poweroff"}), ("/api/comfort", {"action": "start", "minutes": 0}), ("/api/launch", {"appid": "620; rm -rf ~"}), ("/api/launch", {"appid": ""}), ("/api/flatpak", {"id": "org.example.App;id", "action": "install"}), ("/api/flatpak", {"id": "org.example.App", "action": "explode"}), ("/api/volume", {"level": 1.5}), ("/api/clipboard", {"text": ""}), ("/api/open", {"what": "anything-else"}), ("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}), ("/api/open", {"what": "shot"}), ("/api/shots/save", {"ids": []}), ("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}), ("/api/shots/save", {"ids": [1]}), ("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}), ("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}), ] for path, body in cases: status, payload = self.post(path, body) self.assertEqual(status, 400, f"{path} {body} -> {payload}") def test_showing_a_shot_needs_it_saved_here(self): status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"}) self.assertEqual(status, 404, payload) def test_screenshot_ids_checked_before_ssh(self): for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"): status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"}) self.assertEqual(status, 400, shot) def test_stream_settings_checked_before_ssh(self): for query in ("h=480", "fps=24", "h=abc", "h=1080&fps=120"): status, _, _ = self.request("GET", f"/api/stream?{query}", headers={"X-Frame-UI": "1"}) self.assertEqual(status, 400, query) def test_bad_bodies(self): conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"}) self.assertEqual(conn.getresponse().status, 400) conn.close() status, _ = self.post("/api/launch", ["not", "an", "object"]) self.assertEqual(status, 400) def test_title_upload_is_inspected_then_discarded(self): # A zip holding a Windows x86-64 program: inspected locally, no SSH until install. buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as z: z.writestr("Tiny Game/Tiny Game.exe", b"MZ" + b"\0" * 0x3A + struct.pack(" {payload}") self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404) self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403) def test_web_install_needs_the_app_page(self): # A website can only open frame-control:// links; it can't call these itself. link = {"url": "https://cdn.example.com/game.apk"} self.assertEqual(self.request("POST", "/api/webinstall/check", link)[0], 403) self.assertEqual(self.request("POST", "/api/webinstall/start", {"id": "x"})[0], 403) status, _, _ = self.request("POST", "/api/webinstall/check", link, {"X-Frame-UI": "1", "Host": f"evil.example:{self.port}"}) self.assertEqual(status, 403) def test_web_install_validation(self): for body in ({}, {"url": 5}, {"url": "http://cdn.example.com/game.apk"}, {"url": "https://10.0.0.2/game.apk"}, {"url": "https://u:p@example.com/game.apk"}, {"url": "https://example.com/"}, {"url": "https://1.1.1.1/game.sh"}, {"manifest": "file:///etc/passwd"}, {"manifest": "https://example.com/m.json", "url": "https://example.com/g.apk"}): status, payload = self.post("/api/webinstall/check", body) self.assertEqual(status, 400, f"{body} -> {payload}") # Only an id from /check starts an install, and only once. self.assertEqual(self.post("/api/webinstall/start", {"id": "made-up"})[0], 400) self.assertEqual(self.request("GET", "/api/webinstall/job?id=x", headers={"X-Frame-UI": "1"})[0], 404) self.assertEqual(self.post("/api/webinstall/cancel", {"job": "x"})[0], 404) def test_unreachable_frame_is_one_clear_offline_error(self): status, _, payload = self.request("GET", "/api/status", headers={"X-Frame-UI": "1"}) body = json.loads(payload) self.assertEqual(status, 503, body) self.assertTrue(body["offline"]) self.assertIn("Can't find the Frame", body["error"]) self.assertIn("frame-control-test.invalid", body["detail"]) # ssh's own words stay available def test_flatpak_install_runs_as_a_job(self): status, started = self.post("/api/flatpak", {"id": "org.example.App", "action": "install"}) self.assertEqual(status, 200, started) for _ in range(200): status, _, payload = self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"}) job = json.loads(payload) if job["done"]: break time.sleep(0.05) self.assertEqual(status, 200) self.assertTrue(job["done"]) self.assertIn("Can't find the Frame", job["error"]) self.assertEqual(self.request("GET", "/api/job?id=nope", headers={"X-Frame-UI": "1"})[0], 404) def test_android_install_checks_the_package_before_starting(self): status, body = self.post("/api/android", {"action": "install", "package": "org.example.not.in.catalogue"}) self.assertNotEqual(status, 200, body) self.assertNotIn("job", body) def test_android_install_of_another_version_runs_as_a_job(self): pkg = "org.example.frame_control.not_in_any_repo" sys.path.insert(0, str(ROOT / "ui")) import frame_apk_versions # The job must fail on the cached lookup, before any download: nothing is cached for this package. self.assertEqual(frame_apk_versions._versions(pkg, cached_only=True)[0], []) status, started = self.post("/api/android", {"action": "install", "package": pkg, "url": f"https://f-droid.org/repo/{pkg}_1.apk"}) self.assertEqual(status, 200, started) for _ in range(200): job = json.loads(self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})[2]) if job["done"]: break time.sleep(0.05) self.assertTrue(job["done"]) self.assertIn("no longer available", job["error"]) def test_unknown_routes(self): self.assertEqual(self.request("GET", "/nope")[0], 404) self.assertEqual(self.post("/api/nope", {})[0], 404) class OneServer(unittest.TestCase): """Two servers for one user would each connect and edit headsets on their own.""" def start(self, env): proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close())) return proc def test_a_second_server_is_refused_until_the_first_exits(self): data = tempfile.mkdtemp(prefix="frame-one-server-") env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid", "FRAME_CONTROL_SERVER_WAIT": "1"} first = self.start(env) self.assertIn("Frame Control on", first.stdout.readline()) second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, capture_output=True, text=True, timeout=60) self.assertEqual(second.returncode, 1) self.assertIn("already running", second.stderr) first.terminate() first.wait(10) self.assertIn("Frame Control on", self.start(env).stdout.readline()) def test_a_private_server_runs_alongside_but_cant_change_headsets(self): """The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs.""" data = tempfile.mkdtemp(prefix="frame-one-server-") env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid", "FRAME_CONTROL_SERVER_WAIT": "1"} self.assertIn("Frame Control on", self.start(env).stdout.readline()) private = self.start({**env, "FRAME_PRIVATE_SSH": "1"}) line = private.stdout.readline() self.assertIn("Frame Control on", line) port = int(line.split("http://127.0.0.1:")[1].split()[0]) conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}), headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"}) r = conn.getresponse() self.assertEqual(r.status, 403, r.read()) @unittest.skipIf(os.name == "nt", "no SIGTERM on Windows") def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self): """The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit.""" env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"), "FRAME_ALIAS": "frame-control-test.invalid"} proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close())) self.assertIn("Frame Control on", proc.stdout.readline()) proc.terminate() self.assertEqual(proc.wait(30), 0, proc.stdout.read()) class ArtworkSettings(unittest.TestCase): """The settings panel's endpoints, with and without the page's X-Frame-UI key.""" def test_settings_need_and_accept_the_ui_key(self): with tempfile.TemporaryDirectory() as home: port = free_port() env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1", "HOME": home, "APPDATA": home, "XDG_DATA_HOME": home} for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"): env.pop(name, None) proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: def request(method, path, body=None, headers=None): conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) conn.request(method, path, body=json.dumps(body).encode() if body is not None else None, headers=headers or {}) r = conn.getresponse() payload = r.read() conn.close() return r.status, payload for _ in range(100): try: if request("GET", "/")[0] == 200: break except OSError: time.sleep(0.05) key = {"X-Frame-UI": "1", "Content-Type": "application/json"} self.assertEqual(request("GET", "/api/settings/artwork")[0], 403) self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403) status, payload = request("GET", "/api/settings/artwork", headers=key) self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False)) status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key) self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True)) self.assertNotIn(b"abc_1", payload) status, payload = request("GET", "/api/settings/artwork", headers=key) self.assertTrue(json.loads(payload)["steamgriddb_configured"]) self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400) finally: proc.terminate() proc.wait(timeout=10) def test_panel_script_uses_the_keyed_api_helper(self): script = (ROOT / "ui" / "artwork-settings.js").read_text(encoding="utf-8") self.assertNotIn("fetch(", script) self.assertIn("api('/api/settings/artwork'", script) page = (ROOT / "ui" / "index.html").read_text(encoding="utf-8") self.assertLess(page.index("async function api("), page.index('