09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
159 lines
5.8 KiB
Python
159 lines
5.8 KiB
Python
"""U1: app scaffold, lifespan, healthz, and the disabled schema surface."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from notebooklm import paths
|
|
from notebooklm.client import NotebookLMClient
|
|
from notebooklm.server.app import create_app
|
|
|
|
from .conftest import TEST_TOKEN, stale_auth_factory
|
|
from .fakes import FakeClient
|
|
|
|
|
|
def test_healthz_is_public_and_minimal() -> None:
|
|
"""GET /healthz (outside /v1, no token) returns exactly {"ok": true}."""
|
|
app = create_app(client_factory=_factory(FakeClient()))
|
|
with TestClient(app) as client:
|
|
resp = client.get("/healthz")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True}
|
|
|
|
|
|
def test_lifespan_opens_exactly_one_client_and_closes_it() -> None:
|
|
"""The lifespan opens the client once on startup and closes it on shutdown."""
|
|
fake = FakeClient()
|
|
opens = 0
|
|
closed = False
|
|
|
|
@asynccontextmanager
|
|
async def factory() -> AsyncIterator[FakeClient]:
|
|
nonlocal opens, closed
|
|
opens += 1
|
|
try:
|
|
yield fake
|
|
finally:
|
|
closed = True
|
|
|
|
app = create_app(client_factory=factory)
|
|
with TestClient(app) as client:
|
|
assert client.get("/healthz").status_code == 200
|
|
assert opens == 1
|
|
assert closed is False
|
|
# Context exit shuts the lifespan down.
|
|
assert opens == 1
|
|
assert closed is True
|
|
|
|
|
|
def test_stale_auth_startup_still_serves_healthz(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A stale profile must not keep the process from serving liveness diagnostics."""
|
|
monkeypatch.setattr(paths, "_active_profile", "before")
|
|
|
|
app = create_app(profile="work", client_factory=stale_auth_factory())
|
|
with TestClient(app) as client:
|
|
resp = client.get("/healthz")
|
|
assert paths.get_active_profile() == "work"
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True}
|
|
assert paths.get_active_profile() == "before"
|
|
|
|
|
|
def test_stale_auth_startup_projects_client_routes_to_auth_error() -> None:
|
|
"""Live-client routes fail through the structured auth envelope, not startup."""
|
|
app = create_app(client_factory=stale_auth_factory())
|
|
headers = {"Authorization": f"Bearer {TEST_TOKEN}", "Host": "127.0.0.1"}
|
|
|
|
with TestClient(
|
|
app, headers=headers, client=("127.0.0.1", 5555), raise_server_exceptions=False
|
|
) as client:
|
|
resp = client.get("/v1/notebooks")
|
|
|
|
assert resp.status_code == 401
|
|
err = resp.json()["error"]
|
|
assert err["category"] == "auth"
|
|
assert err["retriable"] is False
|
|
assert err["hint"] == "Re-authenticate and retry."
|
|
|
|
|
|
def test_stale_auth_startup_raises_fresh_client_error_per_request() -> None:
|
|
"""The stored startup error must not grow tracebacks across repeated requests."""
|
|
app = create_app(client_factory=stale_auth_factory())
|
|
headers = {"Authorization": f"Bearer {TEST_TOKEN}", "Host": "127.0.0.1"}
|
|
|
|
with TestClient(
|
|
app, headers=headers, client=("127.0.0.1", 5555), raise_server_exceptions=False
|
|
) as client:
|
|
stored_error = app.state.notebooklm.client_error
|
|
assert stored_error is not None
|
|
assert stored_error.__traceback__ is None
|
|
|
|
assert client.get("/v1/notebooks").status_code == 401
|
|
assert stored_error.__traceback__ is None
|
|
|
|
assert client.get("/v1/notebooks").status_code == 401
|
|
assert stored_error.__traceback__ is None
|
|
|
|
|
|
def test_non_auth_client_startup_failure_is_not_swallowed() -> None:
|
|
"""Only normalized auth bootstrap failures are degraded into diagnostic state."""
|
|
|
|
@asynccontextmanager
|
|
async def factory() -> AsyncIterator[FakeClient]:
|
|
raise FileNotFoundError("missing storage_state.json")
|
|
yield FakeClient() # pragma: no cover - makes this an async context manager
|
|
|
|
app = create_app(client_factory=factory)
|
|
with pytest.raises(FileNotFoundError, match="missing storage_state.json"), TestClient(app):
|
|
pass
|
|
|
|
|
|
def test_create_app_default_factory_threads_profile(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
"""#1769: create_app(profile=X) must reach from_storage(profile=X) through the real
|
|
default-factory closure — not just the create_app boundary. Guards the mocked-boundary
|
|
blind spot: a regression to `lambda: _default_factory()` (dropping profile) would pass
|
|
every test that mocks create_app itself.
|
|
|
|
Patches the imported ``NotebookLMClient`` class object (not an import-string), which is
|
|
the robust form the ADR-0007 no-forbidden-monkeypatch guard permits.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
@asynccontextmanager
|
|
async def _fake_ctx() -> AsyncIterator[FakeClient]:
|
|
yield FakeClient()
|
|
|
|
def _spy_from_storage(**kwargs: object) -> AbstractAsyncContextManager[FakeClient]:
|
|
seen.update(kwargs)
|
|
return _fake_ctx()
|
|
|
|
monkeypatch.setattr(NotebookLMClient, "from_storage", staticmethod(_spy_from_storage))
|
|
app = create_app(profile="work") # no client_factory → exercises the default factory
|
|
with TestClient(app) as client:
|
|
assert client.get("/healthz").status_code == 200
|
|
assert seen == {"profile": "work"}
|
|
|
|
|
|
def test_docs_and_openapi_are_disabled() -> None:
|
|
"""The unauthenticated schema UI is off (no tokenless surface)."""
|
|
app = create_app(client_factory=_factory(FakeClient()))
|
|
with TestClient(app) as client:
|
|
assert client.get("/docs").status_code == 404
|
|
assert client.get("/redoc").status_code == 404
|
|
assert client.get("/openapi.json").status_code == 404
|
|
|
|
|
|
def _factory(client: FakeClient): # type: ignore[no-untyped-def]
|
|
@asynccontextmanager
|
|
async def factory() -> AsyncIterator[FakeClient]:
|
|
yield client
|
|
|
|
return factory
|