From 2e23c535d8a16aa288a9d07349173e2acae1ef56 Mon Sep 17 00:00:00 2001 From: DaveHomeAssist Date: Sat, 13 Jun 2026 09:39:22 -0400 Subject: [PATCH 1/2] Add e2e + manual QA test harness Two new layers on top of the in-process unit suite: - tests/e2e/: boots the real ASGI app under uvicorn on an ephemeral port and drives it over a real socket (stdlib client). Validates routing/redirects, CORS over the wire, /speak dispatch, and PATCH /config redaction with a real config-file round-trip. Audio/model leaf calls stubbed (no torch/kokoro). Marked 'e2e'; skips cleanly if uvicorn absent. - docs/QA.md + scripts/qa_probe.py + scripts/smoke.sh: manual/dev layer for what CI can't do (real audio, model download, live keys, the extension). qa_probe drives a running daemon; smoke.sh boots it as a subprocess. 72 tests pass (51 unit + 21 e2e); 'pytest -m "not e2e"' runs the fast 51. uvicorn added to requirements-dev.txt; e2e marker registered in pytest.ini. --- CLAUDE.md | 30 +++++-- docs/QA.md | 86 +++++++++++++++++++ pytest.ini | 2 + requirements-dev.txt | 2 +- scripts/qa_probe.py | 142 +++++++++++++++++++++++++++++++ scripts/smoke.sh | 47 +++++++++++ tests/e2e/conftest.py | 161 ++++++++++++++++++++++++++++++++++++ tests/e2e/test_live_api.py | 86 +++++++++++++++++++ tests/e2e/test_live_cors.py | 63 ++++++++++++++ 9 files changed, 613 insertions(+), 6 deletions(-) create mode 100644 docs/QA.md create mode 100755 scripts/qa_probe.py create mode 100755 scripts/smoke.sh create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_live_api.py create mode 100644 tests/e2e/test_live_cors.py diff --git a/CLAUDE.md b/CLAUDE.md index 8d1663e..bfdfc19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,11 +41,31 @@ Local-first text-to-speech desktop app. Kokoro 82M model runs entirely on-device ## Testing -- **Install dev deps**: `pip install -r requirements-dev.txt` (pytest + fastapi + httpx; no torch/kokoro needed) -- **Run**: `python -m pytest` from the repo root -- The suite mocks the heavy ML/audio stack (numpy, sounddevice, soundfile, kokoro) via fixtures in `tests/conftest.py`, so it runs on any Python 3.10-3.12 without a model download or audio hardware. -- Coverage: config load/merge/corruption, the Kokoro `speak()` flow, audio helpers, every REST endpoint, the CORS policy (regression guard for issue 002), API-key redaction, and the OpenAI/ElevenLabs fallbacks. -- CI runs the suite on push/PR across Python 3.10-3.12 (`.github/workflows/tests.yml`). +Three layers, fastest first: + +1. **Unit / integration** (`tests/test_*.py`) — in-process via Starlette + `TestClient`. Mocks the heavy ML/audio stack (numpy, sounddevice, soundfile, + kokoro) via fixtures in `tests/conftest.py`. Covers config + load/merge/corruption, the Kokoro `speak()` flow, audio helpers, every REST + endpoint, the CORS policy (regression guard for issue 002), API-key + redaction, and the OpenAI/ElevenLabs fallbacks. +2. **Live end-to-end** (`tests/e2e/`) — boots the real ASGI app under uvicorn on + an ephemeral port and drives it over a real socket (stdlib HTTP client). + Proves routing/redirects, CORS behaviour over the wire, and that + `PATCH /config` round-trips secrets to a real file while redacting the + response. Audio/model leaf calls are stubbed, so it still needs no torch/kokoro. +3. **Manual / dev QA** (`docs/QA.md` + `scripts/qa_probe.py`) — for what CI + can't do: real audio, the model download, live API keys, and the Chrome + extension. `qa_probe.py` drives a *running* daemon (`python main.py`). + +Commands: + +- **Install dev deps**: `pip install -r requirements-dev.txt` (pytest, fastapi, httpx, uvicorn; no torch/kokoro) +- **Everything**: `python -m pytest` +- **Fast unit only**: `python -m pytest -m "not e2e"` +- **Live e2e only**: `python -m pytest tests/e2e` +- **Subprocess liveness smoke**: `scripts/smoke.sh` +- CI runs the full suite (layers 1 + 2) on push/PR across Python 3.10-3.12 (`.github/workflows/tests.yml`). ## Deployment diff --git a/docs/QA.md b/docs/QA.md new file mode 100644 index 0000000..8b5c674 --- /dev/null +++ b/docs/QA.md @@ -0,0 +1,86 @@ +# ReadOut — Manual QA Checklist + +Automated tests cover the HTTP contract, config logic, CORS policy, and engine +dispatch (see [Testing](../CLAUDE.md#testing)). They deliberately stub the +parts that need a human or real hardware. This checklist covers **only those +parts**: real audio, the model download, engine switching with live keys, and +the Chrome extension. + +Run these on a real dev machine against a real daemon (`python main.py`), not +in CI. The `scripts/qa_probe.py` driver talks to the running daemon for you. + +--- + +## 0. Pre-flight + +- [ ] `pip install -r requirements.txt` (full runtime stack incl. kokoro/torch) +- [ ] `brew install espeak-ng` (macOS) — required by Kokoro +- [ ] Start the daemon: `python main.py` +- [ ] In another shell: `python scripts/qa_probe.py status` → `HTTP 200`, `"status"` is `loading` or `ready` + +## 1. First-run model download (Kokoro) + +- [ ] On a machine with no `~/.readout/.model_ready`, start the daemon +- [ ] `qa_probe.py status` reports `"status": "loading"` and `"model_ready": false` initially +- [ ] After the ~300 MB download + load, status flips to `"ready"` / `"model_ready": true` +- [ ] `~/.readout/.model_ready` now exists; a restart comes up `ready` immediately + +## 2. Real audio playback (Kokoro) + +- [ ] `qa_probe.py speak "Hello from ReadOut"` → audio plays through the default output +- [ ] `--voice af_sky` / `--voice am_adam` audibly change the voice +- [ ] `--speed 1.5` is faster, `--speed 0.75` slower +- [ ] While audio is playing, `qa_probe.py stop` (or a new `speak`) halts it promptly + +## 3. File save + +- [ ] `qa_probe.py speak "save test" --save` → response has `saved_to`; a `.wav` exists there +- [ ] Set `always_save` (`qa_probe.py config` is keys-only; edit `~/.readout/config.json` or use the control panel) → every `speak` writes a file +- [ ] Default save dir is `~/Desktop/ReadOut` unless `save_dir` is overridden + +## 4. Engine switching (OpenAI / ElevenLabs) — needs live keys + +- [ ] `qa_probe.py config --engine openai --openai-key sk-...` → `HTTP 200`, response shows `"openai_api_key": "***"` (redacted) and the command prints no warning +- [ ] `~/.readout/config.json` contains the **real** key (redaction is response-only) +- [ ] `qa_probe.py speak "openai voice test" --voice nova` → OpenAI audio plays; `stop` halts it +- [ ] Switch to `--engine elevenlabs --elevenlabs-key ...`; `speak` plays ElevenLabs audio + - Note: ElevenLabs streams MP3; if playback fails with a decode error, the + local `soundfile`/`libsndfile` build lacks MP3 support (needs ≥ 1.1) — see issue 003 context +- [ ] A bad key surfaces a clean `{"status":"error","message":"... 401 ..."}`, not a stack trace +- [ ] Switch back: `qa_probe.py config --engine kokoro` + +## 5. Control panel (served by the daemon) + +- [ ] Open `http://127.0.0.1:7778/` → redirects to `/control`, panel renders +- [ ] The panel can change voice/speed/engine and trigger speak/stop +- [ ] The panel does **not** display stored API keys (the server never sends them back) + +## 6. Chrome extension + +- [ ] Load unpacked: `chrome://extensions` → Developer mode → Load unpacked → `extension/` +- [ ] Select text on any page → right-click → "Read aloud via ReadOut" → audio plays +- [ ] Toolbar popup: status indicator shows daemon up/down; Play/Stop work; settings persist +- [ ] With the daemon stopped, the popup shows an unreachable/error state (no crash) + +## 7. Security smoke (the issue-002 regression) + +- [ ] `python scripts/qa_probe.py cors --origin https://evil.com` → prints **BLOCKED** (exit 0) +- [ ] `... cors --origin chrome-extension://abcdefghijklmnopabcdefghijklmnop` → prints **ALLOWED** +- [ ] In a random website's DevTools console, `fetch('http://127.0.0.1:7778/status')` is blocked by CORS +- [ ] `qa_probe.py config --openai-key sk-canary` then confirm `sk-canary` never appears in the HTTP response (the driver warns loudly if it does) +- [ ] Known gap (issue 003): a cross-origin page **can** still fire `POST /stop` as a side effect — verify impact is limited to halting playback + +## 8. Platform: macOS 26 Tk skip (issue 001) + +- [ ] On macOS 26+, the daemon + tray run without launching the Tk window (no `GetRGBA`/NSApplication crash) +- [ ] On macOS < 26 / other platforms, the Tk window still appears as before + +--- + +### Quick automated cross-checks (optional, not a substitute for the above) + +```bash +scripts/smoke.sh # boots the real app in a subprocess, probes liveness + CORS +python -m pytest tests/e2e -v # live-server harness over a real socket (audio stubbed) +python -m pytest -m "not e2e" # fast in-process unit/integration suite only +``` diff --git a/pytest.ini b/pytest.ini index 25d69b5..8980daa 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,5 +3,7 @@ testpaths = tests python_files = test_*.py python_functions = test_* addopts = -ra -q +markers = + e2e: live-server end-to-end tests (boot uvicorn on a real socket; need the uvicorn dev dep) filterwarnings = ignore::DeprecationWarning diff --git a/requirements-dev.txt b/requirements-dev.txt index 5bdcf4b..9edafca 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,4 @@ pytest>=7.0 fastapi httpx<0.28 # starlette TestClient transport -uvicorn # live source HTTP smoke / release_preflight -RunSourceSmoke +uvicorn # tests/e2e + live source HTTP smoke / release_preflight -RunSourceSmoke diff --git a/scripts/qa_probe.py b/scripts/qa_probe.py new file mode 100755 index 0000000..3ab0a48 --- /dev/null +++ b/scripts/qa_probe.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""qa_probe.py — manual QA driver for a RUNNING ReadOut daemon. + +Unlike the pytest suites (which stub audio/model), this talks to the real +daemon you started with `python main.py`, so `speak` plays real audio through +the real engine. Use it to sanity-check a dev build or a packaged .app, and to +verify the CORS lockdown from the command line. + +Stdlib only — no install needed. Run `python main.py` first, then: + + python scripts/qa_probe.py status + python scripts/qa_probe.py voices + python scripts/qa_probe.py speak "Hello from ReadOut" --voice af_sky --speed 1.1 + python scripts/qa_probe.py stop + python scripts/qa_probe.py config --engine openai --openai-key sk-... + python scripts/qa_probe.py cors --origin https://evil.com # expect: BLOCKED + +Add --url http://127.0.0.1:7778 to target a non-default host/port. +""" +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request + +DEFAULT_URL = "http://127.0.0.1:7778" + + +def _call(url, path, method="GET", body=None, headers=None): + data = json.dumps(body).encode() if body is not None else None + h = dict(headers or {}) + if data: + h.setdefault("Content-Type", "application/json") + req = urllib.request.Request(url + path, data=data, headers=h, method=method) + try: + r = urllib.request.urlopen(req, timeout=15) + return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read().decode() + except urllib.error.HTTPError as e: + return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read().decode() + except urllib.error.URLError as e: + print(f"ERROR: cannot reach {url} — is the daemon running? ({e.reason})", file=sys.stderr) + raise SystemExit(2) + + +def _show(status, body): + print(f"HTTP {status}") + try: + print(json.dumps(json.loads(body), indent=2)) + except json.JSONDecodeError: + print(body[:500]) + + +def cmd_status(a): + _show(*_call(a.url, "/status")[::2]) + + +def cmd_voices(a): + _show(*_call(a.url, "/voices")[::2]) + + +def cmd_speak(a): + body = {"text": a.text, "save": a.save} + if a.voice: + body["voice"] = a.voice + if a.speed: + body["speed"] = a.speed + _show(*_call(a.url, "/speak", "POST", body)[::2]) + + +def cmd_stop(a): + _show(*_call(a.url, "/stop", "POST")[::2]) + + +def cmd_config(a): + body = {} + if a.engine: + body["engine"] = a.engine + if a.voice: + body["voice"] = a.voice + if a.openai_key is not None: + body["openai_api_key"] = a.openai_key + if a.elevenlabs_key is not None: + body["elevenlabs_api_key"] = a.elevenlabs_key + if not body: + print("Nothing to update. Pass --engine/--voice/--openai-key/--elevenlabs-key.") + return + status, _, resp = _call(a.url, "/config", "PATCH", body) + _show(status, resp) + # Defensive check: the response must never echo a plaintext key. + for secret in (a.openai_key, a.elevenlabs_key): + if secret and secret in resp: + print("\n*** WARNING: a plaintext API key appeared in the response! ***") + + +def cmd_cors(a): + """Send an Origin header and report whether CORS would allow it.""" + status, headers, _ = _call(a.url, "/status", headers={"Origin": a.origin}) + acao = headers.get("access-control-allow-origin") + if acao == a.origin: + print(f"ALLOWED — server echoed access-control-allow-origin: {acao}") + elif acao == "*": + print("WILDCARD — server returned '*' (this is the issue-002 regression!)") + raise SystemExit(1) + else: + print(f"BLOCKED — no matching access-control-allow-origin for {a.origin!r}") + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--url", default=DEFAULT_URL, help=f"daemon base URL (default {DEFAULT_URL})") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("status").set_defaults(func=cmd_status) + sub.add_parser("voices").set_defaults(func=cmd_voices) + sub.add_parser("stop").set_defaults(func=cmd_stop) + + sp = sub.add_parser("speak") + sp.add_argument("text") + sp.add_argument("--voice") + sp.add_argument("--speed", type=float) + sp.add_argument("--save", action="store_true") + sp.set_defaults(func=cmd_speak) + + cp = sub.add_parser("config") + cp.add_argument("--engine", choices=["kokoro", "openai", "elevenlabs"]) + cp.add_argument("--voice") + cp.add_argument("--openai-key", dest="openai_key") + cp.add_argument("--elevenlabs-key", dest="elevenlabs_key") + cp.set_defaults(func=cmd_config) + + cc = sub.add_parser("cors") + cc.add_argument("--origin", required=True, help="e.g. https://evil.com") + cc.set_defaults(func=cmd_cors) + + args = p.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..5d811b1 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# smoke.sh — boot the real ASGI app in a subprocess and probe it over HTTP. +# +# Complements the pytest e2e harness (which runs uvicorn in a thread): this +# proves the app starts as a *separate process* via the same entrypoint used in +# production, with an isolated HOME so it never touches your real ~/.readout. +# Requires the runtime deps (fastapi + uvicorn); does NOT need torch/kokoro +# because /status and /voices don't load the model. +# +# Usage: scripts/smoke.sh [port] (default 7799) +set -euo pipefail + +PORT="${1:-7799}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMPHOME="$(mktemp -d)" +export HOME="$TMPHOME" # isolate ~/.readout for this run + +cleanup() { + [ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true + rm -rf "$TMPHOME" +} +trap cleanup EXIT + +cd "$ROOT" +python -m uvicorn server:app --host 127.0.0.1 --port "$PORT" --log-level warning & +SRV_PID=$! + +base="http://127.0.0.1:$PORT" +ready="" +for _ in $(seq 1 50); do + if curl -sf "$base/status" >/dev/null 2>&1; then ready=1; break; fi + sleep 0.2 +done +[ -n "$ready" ] || { echo "FAIL: server did not become ready on $base"; exit 1; } + +curl -sf "$base/status" | grep -q '"version"' && echo "OK /status" +curl -sf "$base/voices" | grep -q '"voices"' && echo "OK /voices" + +# CORS regression guard: a hostile origin must NOT be echoed back. +acao="$(curl -sf -D - -o /dev/null -H 'Origin: https://evil.com' "$base/status" \ + | tr -d '\r' | awk -F': ' 'tolower($1)=="access-control-allow-origin"{print $2}')" +if [ "$acao" = "*" ] || [ "$acao" = "https://evil.com" ]; then + echo "FAIL: hostile origin allowed by CORS (got '$acao')"; exit 1 +fi +echo "OK CORS rejects hostile origin" + +echo "SMOKE PASSED" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..a6dc7fb --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,161 @@ +"""End-to-end harness fixtures — boot the real server over a real socket. + +The in-process unit suite (tests/test_server_*.py) exercises `server.app` via +Starlette's TestClient, which never opens a socket and never runs uvicorn's +lifespan. This layer fills that gap: it launches the actual ASGI app under +uvicorn in a background thread on an ephemeral port and drives it with a plain +stdlib HTTP client. That validates the parts only a live process can prove — +the server binds and serves, CORS middleware behaves identically over the wire, +config round-trips to a real file, and `GET /` really redirects. + +Heavy leaf calls (the Kokoro `speak()` / audio playback) are stubbed so the +harness needs neither torch/kokoro nor audio hardware and runs in CI. +""" +from __future__ import annotations + +import json +import socket +import threading +import time +import urllib.error +import urllib.request + +import pytest + +# The e2e layer needs the real server, so skip cleanly if it isn't installed +# (e.g. a minimal `pip install pytest` unit-only environment). +pytest.importorskip("uvicorn") + +pytestmark = pytest.mark.e2e + + +def _free_port() -> int: + """Grab an OS-assigned free TCP port on the loopback interface.""" + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Disable auto-following so tests can assert on 3xx responses directly.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class Resp: + """Lightweight response wrapper. `headers` keys are lower-cased.""" + + def __init__(self, status: int, headers: dict, body: str): + self.status = status + self.headers = headers + self.body = body + + def json(self): + return json.loads(self.body) + + +class Client: + """Minimal real-socket HTTP client (stdlib only — no httpx/requests).""" + + def __init__(self, base: str, config_path: str | None = None): + self.base = base + self.config_path = config_path + self._opener = urllib.request.build_opener(_NoRedirect) + + def request(self, method, path, json_body=None, headers=None) -> Resp: + data = None + h = dict(headers or {}) + if json_body is not None: + data = json.dumps(json_body).encode() + h.setdefault("Content-Type", "application/json") + req = urllib.request.Request(self.base + path, data=data, headers=h, method=method) + try: + r = self._opener.open(req, timeout=10) + status, raw_headers, body = r.status, r.getheaders(), r.read().decode() + except urllib.error.HTTPError as e: + # 4xx/5xx still carry headers we want to inspect (CORS preflight 400). + status, raw_headers, body = e.code, list(e.headers.items()), e.read().decode() + return Resp(status, {k.lower(): v for k, v in raw_headers}, body) + + def get(self, path, **kw): + return self.request("GET", path, **kw) + + def post(self, path, **kw): + return self.request("POST", path, **kw) + + def patch(self, path, **kw): + return self.request("PATCH", path, **kw) + + def options(self, path, **kw): + return self.request("OPTIONS", path, **kw) + + +@pytest.fixture(scope="module") +def live_server(tmp_path_factory): + """Run `server.app` under uvicorn in a thread; yield a Client for it. + + Module-scoped: one real server per e2e test module (its own port + temp + ~/.readout). Config/model paths and the heavy speak()/stop helpers are + patched before the server serves a single request. + """ + import uvicorn + + import config + import tts_engine + import server + + cfg_dir = tmp_path_factory.mktemp("readout_home") / ".readout" + cfg_path = cfg_dir / "config.json" + + mp = pytest.MonkeyPatch() + mp.setattr(config, "CONFIG_DIR", str(cfg_dir)) + mp.setattr(config, "CONFIG_PATH", str(cfg_path)) + mp.setattr(tts_engine, "MODEL_READY_FLAG", str(cfg_dir / ".model_ready")) + # Stub the heavy Kokoro path + stop so /speak and /stop work without torch, + # the 300 MB model download, or audio hardware. Echo inputs back so the + # dispatch wiring (server → tts_engine) stays observable end to end. + mp.setattr( + tts_engine, + "speak", + lambda text, voice=None, speed=None, save=False: { + "status": "playing", + "engine": "kokoro", + "voice": voice or "af_heart", + "speed": speed or 1.0, + "save": save, + }, + ) + mp.setattr(tts_engine, "stop_audio", lambda: None) + + port = _free_port() + uconf = uvicorn.Config(server.app, host="127.0.0.1", port=port, log_level="warning") + srv = uvicorn.Server(uconf) + thread = threading.Thread(target=srv.run, daemon=True) + thread.start() + + base = f"http://127.0.0.1:{port}" + client = Client(base, config_path=str(cfg_path)) + + # Wait for the socket to start serving (uvicorn boot is ~0.2-0.5s). + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + if client.get("/status").status == 200: + break + except urllib.error.URLError: + time.sleep(0.1) + else: + srv.should_exit = True + thread.join(timeout=5) + mp.undo() + raise RuntimeError(f"live server never became ready on {base}") + + try: + yield client + finally: + srv.should_exit = True + thread.join(timeout=5) + mp.undo() diff --git a/tests/e2e/test_live_api.py b/tests/e2e/test_live_api.py new file mode 100644 index 0000000..a711fa6 --- /dev/null +++ b/tests/e2e/test_live_api.py @@ -0,0 +1,86 @@ +"""End-to-end API tests against a live uvicorn server (real sockets). + +Mirrors the in-process endpoint suite but proves the contract holds through a +real HTTP stack: routing, redirects, JSON (de)serialisation, validation, and — +uniquely — that PATCH /config round-trips secrets to an actual file on disk +while redacting them in the response body. +""" +from __future__ import annotations + +import json + +import pytest + +pytestmark = pytest.mark.e2e + + +# ── Routing / static ────────────────────────────────────────────────────────── + +def test_root_redirects_to_control(live_server): + r = live_server.get("/") + assert r.status in (302, 307) + assert r.headers["location"] == "/control" + + +def test_control_panel_serves_html(live_server): + r = live_server.get("/control") + assert r.status == 200 + assert "text/html" in r.headers["content-type"] + assert "ReadOut Control Panel" in r.body + + +# ── /status, /voices ────────────────────────────────────────────────────────── + +def test_status_reports_version_and_engine(live_server): + data = live_server.get("/status").json() + assert data["version"] == "1.0.0" + assert data["engine"] == "kokoro" + assert data["status"] in ("ready", "loading") + assert data["model_ready"] is False # temp HOME has no .model_ready flag + + +def test_voices_returns_catalogue(live_server): + data = live_server.get("/voices").json() + assert isinstance(data["voices"], list) and data["voices"] + assert {"id", "label"} <= data["voices"][0].keys() + + +# ── /speak dispatch (Kokoro path stubbed in the fixture) ────────────────────── + +def test_speak_dispatches_to_engine(live_server): + r = live_server.post("/speak", json_body={"text": "hello", "voice": "af_sky", "speed": 1.1}) + body = r.json() + assert r.status == 200 + assert body["status"] == "playing" + assert body["voice"] == "af_sky" + assert body["speed"] == 1.1 + + +def test_speak_requires_text(live_server): + r = live_server.post("/speak", json_body={"voice": "af_heart"}) + assert r.status == 422 # pydantic: text is required + + +def test_stop_returns_stopped(live_server): + r = live_server.post("/stop") + assert r.json() == {"status": "stopped"} + + +# ── PATCH /config: redaction over the wire + real file round-trip ───────────── + +def test_patch_config_redacts_in_response_but_persists_to_disk(live_server): + r = live_server.patch("/config", json_body={"openai_api_key": "sk-live-e2e", "voice": "am_adam"}) + body = r.json() + assert body["status"] == "updated" + assert body["config"]["openai_api_key"] == "***" + assert body["config"]["voice"] == "am_adam" + assert "sk-live-e2e" not in r.body # plaintext never crosses the wire + + # The daemon still needs the real key, so it must reach the config file. + on_disk = json.loads(open(live_server.config_path, encoding="utf-8").read()) + assert on_disk["openai_api_key"] == "sk-live-e2e" + + +def test_patch_config_unset_secret_is_empty_not_stars(live_server): + r = live_server.patch("/config", json_body={"voice": "af_heart"}) + assert r.json()["config"]["elevenlabs_api_key"] == "" diff --git a/tests/e2e/test_live_cors.py b/tests/e2e/test_live_cors.py new file mode 100644 index 0000000..90d8d2c --- /dev/null +++ b/tests/e2e/test_live_cors.py @@ -0,0 +1,63 @@ +"""End-to-end CORS tests against a live uvicorn server. + +The in-process CORS suite (tests/test_server_cors.py) already pins the policy +via TestClient. This re-checks the *same* contract over a real socket, so a +divergence between Starlette's test transport and the deployed uvicorn stack +(middleware ordering, preflight short-circuit) can't hide the regression that +issue 002 was about. +""" +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.e2e + +EXT_ORIGIN = "chrome-extension://abcdefghijklmnopabcdefghijklmnop" + +ALLOWED = [EXT_ORIGIN, "http://localhost", "http://localhost:5173", "http://127.0.0.1:7778"] +DISALLOWED = [ + "https://evil.com", + "http://evil.com", + "http://localhost.evil.com", # suffix attack on the localhost prefix + "https://localhost", # https is not a configured dev origin + "null", +] + + +def test_policy_is_never_wildcard(live_server): + r = live_server.get("/status", headers={"Origin": "https://evil.com"}) + assert r.headers.get("access-control-allow-origin") != "*" + + +@pytest.mark.parametrize("origin", ALLOWED) +def test_allowed_origin_is_echoed(live_server, origin): + r = live_server.get("/status", headers={"Origin": origin}) + assert r.headers.get("access-control-allow-origin") == origin + + +@pytest.mark.parametrize("origin", DISALLOWED) +def test_disallowed_origin_gets_no_cors_header(live_server, origin): + r = live_server.get("/status", headers={"Origin": origin}) + assert "access-control-allow-origin" not in r.headers + + +def test_preflight_allows_patch_from_extension(live_server): + r = live_server.options( + "/config", + headers={ + "Origin": EXT_ORIGIN, + "Access-Control-Request-Method": "PATCH", + "Access-Control-Request-Headers": "content-type", + }, + ) + assert r.status in (200, 204) + assert "PATCH" in r.headers.get("access-control-allow-methods", "") + assert r.headers.get("access-control-allow-origin") == EXT_ORIGIN + + +def test_preflight_blocked_for_disallowed_origin(live_server): + r = live_server.options( + "/config", + headers={"Origin": "https://evil.com", "Access-Control-Request-Method": "PATCH"}, + ) + assert "access-control-allow-origin" not in r.headers From a43f2ae549525c5450f57b82fb0359aa50728044 Mon Sep 17 00:00:00 2001 From: Dave Robertson Date: Sat, 11 Jul 2026 20:27:59 -0400 Subject: [PATCH 2/2] Adapt e2e CORS tests to main's configured-origin policy Since this branch was cut, main hardened the CORS policy: extension origins are no longer allowed by default and must be configured via allowed_origins / READOUT_ALLOWED_ORIGINS (see tests/test_server_cors.py, which configures the same test extension ID). The live-server fixture now sets READOUT_ALLOWED_ORIGINS to the test extension origin so the over-the-wire CORS assertions exercise the same contract. --- tests/e2e/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index a6dc7fb..2804b48 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -28,6 +28,13 @@ pytestmark = pytest.mark.e2e +# Extension origins are only CORS-allowed when explicitly configured +# (server.py consults `allowed_origins` config / READOUT_ALLOWED_ORIGINS on +# every request). The live server allows this test extension ID so +# tests/e2e/test_live_cors.py can assert the echo over the wire — the same +# origin tests/test_server_cors.py configures for the in-process suite. +EXT_ORIGIN = "chrome-extension://abcdefghijklmnopabcdefghijklmnop" + def _free_port() -> int: """Grab an OS-assigned free TCP port on the loopback interface.""" @@ -129,6 +136,8 @@ def live_server(tmp_path_factory): }, ) mp.setattr(tts_engine, "stop_audio", lambda: None) + # Allow the test extension origin (see EXT_ORIGIN above); read per request. + mp.setenv("READOUT_ALLOWED_ORIGINS", EXT_ORIGIN) port = _free_port() uconf = uvicorn.Config(server.app, host="127.0.0.1", port=port, log_level="warning")