"""Unit tests for unshackle.core.api.routes.setup_routes wiring + CORS + auth gating. We build small aiohttp apps in-test with setup_routes(), mirroring what unshackle/commands/serve.py does. We avoid hitting the real handlers by stubbing the route table for selected paths. """ from __future__ import annotations import asyncio import json from collections import Counter, defaultdict import pytest from aiohttp import web from unshackle.core.api import routes from unshackle.core.api.compression import compression_middleware from unshackle.core.api.errors import APIError, APIErrorCode from unshackle.core.api.routes import api_handler, cors_middleware, heartbeat, setup_routes pytestmark = pytest.mark.unit @pytest.fixture def make_app(): """Factory that builds an aiohttp app for tests.""" def factory(remote_only: bool = False, with_auth_middleware: bool = False): middlewares = [cors_middleware, compression_middleware] if with_auth_middleware: middlewares.insert(1, no_key_required_auth()) app = web.Application(middlewares=middlewares) app["config"] = {"users": {}} app["debug_api"] = False setup_routes(app, remote_only=remote_only) return app return factory def no_key_required_auth(): """Mirror serve.py's api_key_authentication middleware: a required X-Secret-Key on every endpoint.""" @web.middleware async def mw(request, handler): secret = request.headers.get("X-Secret-Key") if not secret: return web.json_response({"status": 401, "message": "Secret Key is Empty."}, status=401) if secret not in request.app["config"]["users"]: return web.json_response({"status": 401, "message": "Secret Key is Invalid."}, status=401) return await handler(request) return mw def collect_paths(app: web.Application) -> list[tuple[str, str]]: return sorted({(r.method, r.resource.canonical) for r in app.router.routes()}) def test_setup_routes_full_mode_wires_all_endpoints(make_app) -> None: app = make_app(remote_only=False) paths = collect_paths(app) expected = { ("GET", "/api/health"), ("GET", "/api/services"), ("POST", "/api/search"), ("POST", "/api/list-titles"), ("POST", "/api/list-tracks"), ("POST", "/api/download"), ("GET", "/api/download/jobs"), ("GET", "/api/download/jobs/{job_id}"), ("DELETE", "/api/download/jobs/{job_id}"), ("POST", "/api/download/jobs/{job_id}/input"), ("POST", "/api/session/create"), ("GET", "/api/session/{session_id}/titles"), ("POST", "/api/session/{session_id}/tracks"), ("POST", "/api/session/{session_id}/segments"), ("POST", "/api/session/{session_id}/license"), ("GET", "/api/session/{session_id}/logs"), ("GET", "/api/session/{session_id}/prompt"), ("POST", "/api/session/{session_id}/prompt"), ("GET", "/api/session/{session_id}"), ("DELETE", "/api/session/{session_id}"), } assert expected.issubset(set(paths)) def test_setup_routes_remote_only_excludes_list_and_download(make_app) -> None: app = make_app(remote_only=True) paths = set(collect_paths(app)) assert ("POST", "/api/list-titles") not in paths assert ("POST", "/api/list-tracks") not in paths assert ("POST", "/api/download") not in paths assert ("GET", "/api/download/jobs") not in paths # The full remote subset is still registered after the single-table collapse. remote_subset = { ("GET", "/api/health"), ("GET", "/api/services"), ("POST", "/api/search"), ("POST", "/api/session/create"), ("GET", "/api/session/{session_id}/titles"), ("POST", "/api/session/{session_id}/tracks"), ("POST", "/api/session/{session_id}/segments"), ("POST", "/api/session/{session_id}/license"), ("GET", "/api/session/{session_id}/logs"), ("GET", "/api/session/{session_id}/prompt"), ("POST", "/api/session/{session_id}/prompt"), ("GET", "/api/session/{session_id}"), ("DELETE", "/api/session/{session_id}"), } assert remote_subset.issubset(paths) async def test_api_handler_maps_apierror(aiohttp_client) -> None: """The error wrapper turns a raised APIError into the same structured response the per-handler except blocks produced (status + code from APIError).""" @api_handler async def boom(request: web.Request) -> web.Response: raise APIError(APIErrorCode.NOT_FOUND, "nope", details={"x": 1}) app = web.Application() app["debug_api"] = False app.router.add_get("/boom", boom) client = await aiohttp_client(app) resp = await client.get("/boom") assert resp.status == 404 # NOT_FOUND -> 404 per errors.APIError.default_http_status body = await resp.json() assert body["status"] == "error" assert body["error_code"] == "NOT_FOUND" assert body["message"] == f"nope (ref {body['ref']})" assert body["details"] == {"x": 1} async def test_heartbeat_keeps_slow_request_alive(aiohttp_client, monkeypatch: pytest.MonkeyPatch) -> None: """A fast route answers unchanged; a slow one streams newlines, then its body, and a late error arrives as 200.""" monkeypatch.setattr(routes, "HEARTBEAT_INTERVAL", 0.05) @heartbeat async def fast(request: web.Request) -> web.Response: return web.json_response({"x": 1}, status=201) @heartbeat async def slow(request: web.Request) -> web.Response: await asyncio.sleep(0.2) return web.json_response({"titles": [1]}) @heartbeat async def slow_fail(request: web.Request) -> web.Response: await asyncio.sleep(0.2) raise APIError(APIErrorCode.SERVICE_ERROR, "late") app = web.Application() app["debug_api"] = False app.router.add_get("/fast", fast) app.router.add_get("/slow", slow) app.router.add_get("/slow_fail", slow_fail) client = await aiohttp_client(app) resp = await client.get("/fast") assert resp.status == 201 and await resp.json() == {"x": 1} resp = await client.get("/slow") text = await resp.text() assert resp.status == 200 and text.startswith("\n") and json.loads(text) == {"titles": [1]} assert resp.headers["X-Accel-Buffering"] == "no" resp = await client.get("/slow_fail") body = json.loads(await resp.text()) assert resp.status == 200 and body["status"] == "error" and body["error_code"] == "SERVICE_ERROR" @pytest.mark.parametrize("delay", [0, 0.2]) async def test_heartbeat_answers_a_service_exit_and_keeps_serving( aiohttp_client, monkeypatch: pytest.MonkeyPatch, delay: float ) -> None: """A SystemExit inside the heartbeat task becomes a SERVICE_ERROR instead of stopping the event loop.""" monkeypatch.setattr(routes, "HEARTBEAT_INTERVAL", 0.05) @heartbeat async def exits(request: web.Request) -> web.Response: await asyncio.sleep(delay) raise SystemExit(1) app = web.Application() app["debug_api"] = False app.router.add_get("/exits", api_handler(exits)) client = await aiohttp_client(app) for _ in range(2): resp = await client.get("/exits") assert json.loads(await resp.text())["error_code"] == "SERVICE_ERROR" async def test_unguarded_route_answers_a_service_exit(aiohttp_client) -> None: async def exits(request: web.Request) -> web.Response: raise SystemExit(1) app = web.Application(middlewares=[routes.no_5xx_middleware]) app.router.add_get("/exits", exits) client = await aiohttp_client(app) for _ in range(2): resp = await client.get("/exits") assert resp.status == 422 and (await resp.json())["error_code"] == "SERVICE_ERROR" async def test_heartbeat_late_error_counts_as_rejected(aiohttp_client, monkeypatch: pytest.MonkeyPatch) -> None: """Stats count a late failure by its real status and the bytes the stream sent (with framing), not as an empty 200.""" from unshackle.core.api.stats import stats, stats_middleware monkeypatch.setattr(routes, "HEARTBEAT_INTERVAL", 0.05) monkeypatch.setattr(stats, "keys", {}) monkeypatch.setattr(stats, "errors", defaultdict(Counter)) @heartbeat async def slow_fail(request: web.Request) -> web.Response: await asyncio.sleep(0.2) raise APIError(APIErrorCode.SERVICE_ERROR, "late") app = web.Application(middlewares=[stats_middleware]) app["debug_api"] = False app.router.add_get("/slow_fail", slow_fail) client = await aiohttp_client(app) resp = await client.get("/slow_fail") sent = len(await resp.read()) (entry,) = stats.keys.values() assert resp.status == 200 and entry.rejected == 1 and entry.bytes_out >= sent > 0 assert stats.errors == {"-": {"SERVICE_ERROR": 1}} @pytest.mark.usefixtures("example_service") @pytest.mark.parametrize(("service", "counted_as"), [("EXAMPLE", "EXAMPLE"), ("NOT_INSTALLED", "-")]) async def test_error_is_counted_by_service_and_logged_with_its_ref( aiohttp_client, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, service: str, counted_as: str ) -> None: """A gzipped error still reaches the stats: counted under an installed service only, and logged with its ref.""" from unshackle.core.api.stats import stats, stats_middleware monkeypatch.setattr(stats, "errors", defaultdict(Counter)) @api_handler async def fail(request: web.Request) -> web.Response: await request.json() raise APIError(APIErrorCode.SERVICE_ERROR, "denied", details={"pad": "x" * 300}) app = web.Application(middlewares=[stats_middleware, compression_middleware]) app["debug_api"] = False app.router.add_post("/fail", fail) client = await aiohttp_client(app) resp = await client.post("/fail", json={"service": service}, headers={"Accept-Encoding": "gzip"}) body = await resp.json() assert resp.headers["Content-Encoding"] == "gzip" assert stats.errors == {counted_as: {"SERVICE_ERROR": 1}} assert f"Error ref {body['ref']}: SERVICE_ERROR 422 POST /fail service={counted_as}" in caplog.text async def test_heartbeat_client_disconnect_is_quiet( aiohttp_client, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """A client that leaves during the heartbeat logs no traceback, and the work runs to the end.""" monkeypatch.setattr(routes, "HEARTBEAT_INTERVAL", 0.02) finished = asyncio.Event() @heartbeat async def slow(request: web.Request) -> web.Response: await asyncio.sleep(0.3) finished.set() return web.json_response({}) app = web.Application() app["debug_api"] = False app.router.add_get("/slow", slow) client = await aiohttp_client(app) resp = await client.get("/slow") await resp.content.read(1) resp.close() await asyncio.wait_for(finished.wait(), 2) await asyncio.sleep(0.1) assert "Error handling request" not in caplog.text async def test_cors_preflight_returns_headers(make_app, aiohttp_client) -> None: app = make_app(remote_only=True) client = await aiohttp_client(app) resp = await client.options("/api/health") assert resp.status == 200 assert resp.headers["Access-Control-Allow-Origin"] == "*" assert "GET" in resp.headers["Access-Control-Allow-Methods"] assert "X-Secret-Key" in resp.headers["Access-Control-Allow-Headers"] async def test_health_endpoint_responds_ok(make_app, aiohttp_client) -> None: app = make_app(remote_only=True) client = await aiohttp_client(app) resp = await client.get("/api/health") assert resp.status == 200 body = await resp.json() assert body["status"] == "ok" assert "version" in body async def test_every_response_carries_a_generic_server_header(make_app, aiohttp_client) -> None: """Returned, raised (404, 405) and streamed responses all lose aiohttp's version banner.""" async def stream(request: web.Request) -> web.StreamResponse: response = web.StreamResponse() await response.prepare(request) await response.write(b"x") return response app = make_app(remote_only=True) app.router.add_get("/stream", stream) client = await aiohttp_client(app) for method, path in [("GET", "/api/health"), ("DELETE", "/api/health"), ("GET", "/api/nope"), ("GET", "/stream")]: resp = await client.request(method, path) assert resp.headers["Server"] == "unshackle", (method, path, resp.status) async def test_services_load_errors_go_to_the_admin_key_only( make_app, aiohttp_client, monkeypatch: pytest.MonkeyPatch ) -> None: from unshackle.core import services as services_module from unshackle.core.config import config monkeypatch.setattr(services_module, "LOAD_ERRORS", ["BAD: failed to import - ImportError: boom"]) monkeypatch.setattr(config, "serve", {"api_secret": "admin", "users": {"user": {"services": ["EXAMPLE"]}}}) client = await aiohttp_client(make_app(remote_only=True)) user = await (await client.get("/api/services", headers={"X-Secret-Key": "user"})).json() admin = await (await client.get("/api/services", headers={"X-Secret-Key": "admin"})).json() assert user["load_errors"] == [] assert admin["load_errors"] == ["BAD: failed to import - ImportError: boom"] async def test_health_needs_an_api_key(make_app, aiohttp_client) -> None: app = make_app(remote_only=True, with_auth_middleware=True) app["config"]["users"]["good-key"] = {} client = await aiohttp_client(app) assert (await client.get("/api/health")).status == 401 assert (await client.get("/api/health", headers={"X-Secret-Key": "good-key"})).status == 200 async def test_auth_middleware_rejects_missing_key(make_app, aiohttp_client) -> None: app = make_app(remote_only=True, with_auth_middleware=True) client = await aiohttp_client(app) resp = await client.get("/api/session/abc") assert resp.status == 401 body = await resp.json() assert "Secret Key" in body["message"] async def test_auth_middleware_rejects_invalid_key(make_app, aiohttp_client) -> None: app = make_app(remote_only=True, with_auth_middleware=True) client = await aiohttp_client(app) resp = await client.get("/api/session/abc", headers={"X-Secret-Key": "wrong"}) assert resp.status == 401 async def test_auth_middleware_accepts_known_key(make_app, aiohttp_client) -> None: app = make_app(remote_only=True, with_auth_middleware=True) app["config"]["users"]["good-key"] = {"devices": []} client = await aiohttp_client(app) resp = await client.get("/api/session/nonexistent", headers={"X-Secret-Key": "good-key"}) # Auth passed; handler then 404s the session; anything other than 401 is fine here. assert resp.status != 401 def test_setup_routes_cdm_only_keeps_health_and_dashboard_only() -> None: """A CDM-only server answers health and the dashboard under /api/, nothing else.""" app = web.Application() setup_routes(app, dashboard=True, cdm_only=True) paths = {path for _, path in collect_paths(app)} assert "/api/health" in paths assert any(path.startswith("/api/dashboard/") for path in paths) assert all(path == "/api/health" or path.startswith("/api/dashboard/") for path in paths) def test_setup_routes_with_docs_registers_each_route_once() -> None: """Swagger registers the routes itself, so docs add none twice and drop none.""" plain, documented = web.Application(), web.Application() setup_routes(plain, dashboard=True) setup_routes(documented, dashboard=True, docs=True) routes_with_docs = Counter((r.method, r.resource.canonical) for r in documented.router.routes()) assert max(routes_with_docs.values()) == 1 assert {route for route in routes_with_docs if not route[1].startswith("/api/docs")} == set(collect_paths(plain))