Files
ledgrab/server/tests/api/routes/test_system_routes.py
T
alexei.dolgolyov 126d8f2449 feat(auth): add auth.expose_docs flag to view API docs without a token
The /docs, /redoc and /openapi.json routes are gated by AuthRequired, so a
browser can't open them on plain navigation (no way to attach a Bearer token).
Add an opt-in auth.expose_docs flag (default off) that relaxes ONLY those three
routes to anonymous access (loopback + LAN) via a new verify_docs_access
dependency. Every real endpoint stays protected, and a startup WARNING fires
when the flag is on.

- config: AuthConfig.expose_docs: bool = False
- auth: verify_docs_access / DocsAccess dependency
- main: docs routes use DocsAccess; startup warning
- default_config.yaml: documented flag
- tests: docs anonymous when exposed; real endpoints still 401
2026-06-11 00:14:48 +03:00

178 lines
6.4 KiB
Python

"""Tests for system routes — health, version.
These tests use the FastAPI TestClient against the real app. The health
and version endpoints do NOT require authentication, so we can test them
without setting up the full dependency injection.
"""
import pytest
from fastapi.testclient import TestClient
from ledgrab import __version__
from ledgrab.config import get_config
def _auth_headers() -> dict[str, str]:
"""Resolve the configured API key lazily so test ordering doesn't matter.
Evaluating ``get_config()`` at module import time produced a stale empty
key when other tests mutated the global config before this module ran.
"""
api_key = next(iter(get_config().auth.api_keys.values()), "")
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
@pytest.fixture(scope="module")
def client():
"""Provide a test client for the main app.
The app module initializes stores from the default config on import,
which is acceptable for read-only endpoints tested here.
"""
from ledgrab.main import app
return TestClient(app, raise_server_exceptions=False)
class TestHealthEndpoint:
def test_health_returns_200(self, client):
resp = client.get("/health")
assert resp.status_code == 200
def test_health_response_structure(self, client):
data = client.get("/health").json()
assert data["status"] == "healthy"
assert data["version"] == __version__
assert "timestamp" in data
def test_health_no_auth_required(self, client):
"""Health endpoint should work without Authorization header."""
resp = client.get("/health")
assert resp.status_code == 200
class TestVersionEndpoint:
def test_version_returns_200(self, client):
resp = client.get("/api/v1/version")
assert resp.status_code == 200
def test_version_response_fields(self, client):
data = client.get("/api/v1/version").json()
assert data["version"] == __version__
assert "python_version" in data
assert data["api_version"] == "v1"
assert "demo_mode" in data
class TestOpenAPIEndpoint:
def test_openapi_available(self, client):
resp = client.get("/openapi.json", headers=_auth_headers())
assert resp.status_code == 200
data = resp.json()
assert "info" in data
assert data["info"]["version"] == __version__
def test_swagger_ui(self, client):
resp = client.get("/docs", headers=_auth_headers())
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_openapi_requires_auth(self, client):
"""Without a valid bearer token, the OpenAPI surface is unreachable."""
resp = client.get("/openapi.json")
assert resp.status_code == 401
def test_swagger_requires_auth(self, client):
resp = client.get("/docs")
assert resp.status_code == 401
class TestExposeDocsFlag:
"""auth.expose_docs relaxes the docs routes to anonymous access."""
@pytest.fixture
def expose_docs(self, monkeypatch):
"""Turn auth.expose_docs ON for the duration of a test."""
monkeypatch.setattr(get_config().auth, "expose_docs", True)
def test_openapi_anonymous_when_exposed(self, client, expose_docs):
resp = client.get("/openapi.json")
assert resp.status_code == 200
assert resp.json()["info"]["version"] == __version__
def test_swagger_anonymous_when_exposed(self, client, expose_docs):
resp = client.get("/docs")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_redoc_anonymous_when_exposed(self, client, expose_docs):
resp = client.get("/redoc")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_real_endpoints_still_protected_when_exposed(self, client, expose_docs):
"""Exposing docs must NOT open up actual API endpoints."""
resp = client.get("/api/v1/system/api-keys")
assert resp.status_code == 401
class TestRootEndpoint:
def test_root_returns_html(self, client):
resp = client.get("/")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
class TestInstalledAppsEndpoint:
def test_requires_auth(self, client):
resp = client.get("/api/v1/system/installed-apps")
assert resp.status_code == 401
def test_empty_off_android(self, client):
"""Desktop test host: is_android() is False, so the bridge wrapper
short-circuits to an empty list."""
resp = client.get("/api/v1/system/installed-apps", headers=_auth_headers())
assert resp.status_code == 200
assert resp.json() == {"apps": [], "count": 0}
def test_returns_apps_when_available(self, client, monkeypatch):
from ledgrab.core.automations import platform_detector as pd
monkeypatch.setattr(
pd,
"list_installed_apps",
lambda: [{"package": "com.netflix.mediaclient", "label": "Netflix"}],
)
resp = client.get("/api/v1/system/installed-apps", headers=_auth_headers())
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 1
assert data["apps"][0] == {"package": "com.netflix.mediaclient", "label": "Netflix"}
class TestSystemInfoEndpoint:
def test_requires_auth(self, client):
resp = client.get("/api/v1/system/info")
assert resp.status_code == 401
def test_desktop_signal(self, client):
resp = client.get("/api/v1/system/info", headers=_auth_headers())
assert resp.status_code == 200
data = resp.json()
assert data["is_android"] is False
assert data["app_match_kind"] == "process"
assert data["usage_access_granted"] is True
def test_android_signal(self, client, monkeypatch):
import ledgrab.utils.platform as plat
from ledgrab.core.automations import platform_detector as pd
monkeypatch.setattr(plat, "is_android", lambda: True)
monkeypatch.setattr(pd, "has_usage_access", lambda: False)
resp = client.get("/api/v1/system/info", headers=_auth_headers())
assert resp.status_code == 200
data = resp.json()
assert data["is_android"] is True
assert data["app_match_kind"] == "package"
assert data["usage_access_granted"] is False