126d8f2449
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
178 lines
6.4 KiB
Python
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
|