mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
/api/health needs X-Secret-Key but stays outside the rate limit. Only the admin key gets load_errors from /api/services, default error details no longer name the exception class, and a prepare signal replaces aiohttp's Server banner on every response while the CDM routes keep their own.
390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""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.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))
|