mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-10 11:12:13 +00:00
test(remote): add unit + e2e suite for remote-services subsystem
Covers RemoteClient/RemoteService, REST routes, handlers, SessionStore, InputBridge, DownloadQueueManager, errors, compression, and serve CLI. E2e tier opts in via --live and can auto-spawn its own serve.
This commit is contained in:
159
tests/remote/unit/test_routes.py
Normal file
159
tests/remote/unit/test_routes.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""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 pytest
|
||||
from aiohttp import web
|
||||
|
||||
from unshackle.core.api.compression import compression_middleware
|
||||
from unshackle.core.api.routes import cors_middleware, 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: required X-Secret-Key
|
||||
on every endpoint except /api/health."""
|
||||
|
||||
@web.middleware
|
||||
async def mw(request, handler):
|
||||
if request.path == "/api/health":
|
||||
return await handler(request)
|
||||
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/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}/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
|
||||
# session endpoints still present
|
||||
assert ("POST", "/api/session/create") in paths
|
||||
assert ("GET", "/api/session/{session_id}/titles") in paths
|
||||
assert ("POST", "/api/session/{session_id}/license") in paths
|
||||
|
||||
|
||||
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, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from unshackle.core.api import routes as routes_mod
|
||||
|
||||
async def _no_update(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(routes_mod.UpdateChecker, "check_for_updates", _no_update)
|
||||
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_health_bypasses_api_key_auth_middleware(make_app, aiohttp_client, monkeypatch) -> None:
|
||||
from unshackle.core.api import routes as routes_mod
|
||||
|
||||
async def _no_update(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(routes_mod.UpdateChecker, "check_for_updates", _no_update)
|
||||
|
||||
app = make_app(remote_only=True, with_auth_middleware=True)
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get("/api/health")
|
||||
assert resp.status == 200 # health bypasses auth
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user