09e9f3545f
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
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
211 lines
9.2 KiB
Python
211 lines
9.2 KiB
Python
"""Layer-B e2e: the REST ``/v1`` server against the **real** NotebookLM API,
|
|
driven entirely in-process over ``httpx.ASGITransport`` (no socket, no
|
|
subprocess).
|
|
|
|
Mirrors ``tests/e2e/test_mcp_http.py``: ``create_app``'s ``client_factory`` seam
|
|
yields the already-open LIVE e2e ``client`` fixture, so the routes hit real
|
|
Google while still exercising the REST-specific surface:
|
|
|
|
* the **bearer gate** (no token → 401) and the **loopback-Host guard**
|
|
(non-loopback ``Host`` → 403, the DNS-rebinding defense),
|
|
* the read routes (``/v1/server/info``, ``/v1/notebooks``,
|
|
``/v1/notebooks/{id}``, ``/v1/notebooks/{id}/sources``) against live data,
|
|
* one live ``POST /v1/notebooks/{id}/chat`` (a real answer).
|
|
|
|
Requires auth and the ``server`` extra (``importorskip`` skips the module when
|
|
FastAPI is absent). Auto-marked ``e2e`` by ``conftest.pytest_itemcollected``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import traceback
|
|
from collections.abc import AsyncIterator
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
# Require the `server` extra; skip the whole module cleanly when FastAPI is absent.
|
|
pytest.importorskip("fastapi")
|
|
|
|
from notebooklm.server._auth import ( # noqa: E402 - after importorskip
|
|
ALLOW_EXTERNAL_BIND_ENV,
|
|
SERVER_TOKEN_ENV,
|
|
)
|
|
from notebooklm.server.app import create_app # noqa: E402 - after importorskip
|
|
|
|
from .conftest import requires_auth # noqa: E402 - after importorskip
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
|
|
#: Arbitrary bearer the in-process app validates against (set via the env below).
|
|
_TOKEN = "e2e-rest-bearer-token"
|
|
#: Bearer + a loopback ``Host`` literal — satisfies both the token gate and the
|
|
#: DNS-rebinding (loopback-Host) guard on every ``/v1`` request.
|
|
_HEADERS = {"Authorization": f"Bearer {_TOKEN}", "Host": "127.0.0.1"}
|
|
|
|
#: Phrases that mark a live chat rate-limit (mirrors the conftest set) — a
|
|
#: server-side throttle, not a client defect, so the chat e2e skips on these.
|
|
_RATE_LIMIT_PHRASES = (
|
|
"rate limit",
|
|
"rate limited",
|
|
"rate-limited",
|
|
"rejected by the api",
|
|
"429",
|
|
"too many requests",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _server_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Configure the bearer the REST app checks against, for every test here."""
|
|
monkeypatch.setenv(SERVER_TOKEN_ENV, _TOKEN)
|
|
# Keep the default loopback-Host guard active: a leaked
|
|
# NOTEBOOKLM_SERVER_ALLOW_EXTERNAL_BIND=1 would disable it and let the
|
|
# non-loopback-Host gate test pass a request it should reject.
|
|
monkeypatch.delenv(ALLOW_EXTERNAL_BIND_ENV, raising=False)
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _live_rest_app(real_client: Any) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""A REST app bound to the LIVE e2e client, driven in-process over ASGI.
|
|
|
|
The ``client_factory`` yields the already-open fixture client and must **not**
|
|
close it on lifespan exit (the fixture owns its lifecycle). ``ASGITransport``
|
|
does not run the ASGI lifespan, so we enter it explicitly to populate
|
|
``app.state`` the way a real ``uvicorn`` boot would.
|
|
"""
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def factory() -> AsyncIterator[Any]:
|
|
yield real_client # fixture-owned; do not aclose here
|
|
|
|
app = create_app(client_factory=factory)
|
|
async with app.router.lifespan_context(app):
|
|
# Pin the ASGI peer to a loopback literal so the auth-gate tests exercise
|
|
# the intended peer condition (require_auth reads request.client.host)
|
|
# instead of relying on httpx's default ASGITransport peer.
|
|
transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 12345))
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
|
|
yield http
|
|
|
|
|
|
@requires_auth
|
|
class TestRestServerLiveAuthGate:
|
|
"""The bearer + loopback-Host gates on the in-process REST transport."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.readonly
|
|
async def test_unauthenticated_is_rejected(self, client: Any) -> None:
|
|
"""A ``/v1`` request with no bearer is rejected (401) before any handler."""
|
|
async with _live_rest_app(client) as http:
|
|
resp = await http.get("/v1/notebooks", headers={"Host": "127.0.0.1"})
|
|
assert resp.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.readonly
|
|
async def test_non_loopback_host_is_rejected(self, client: Any) -> None:
|
|
"""A spoofed non-loopback ``Host`` is rejected (403) even with the bearer."""
|
|
async with _live_rest_app(client) as http:
|
|
resp = await http.get(
|
|
"/v1/notebooks",
|
|
headers={"Authorization": f"Bearer {_TOKEN}", "Host": "evil.example.com"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
@requires_auth
|
|
class TestRestServerLiveReads:
|
|
"""Read routes against live account data."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.readonly
|
|
async def test_server_info(self, client: Any) -> None:
|
|
"""``GET /v1/server/info`` reports the version and the auth-probe block."""
|
|
async with _live_rest_app(client) as http:
|
|
resp = await http.get("/v1/server/info", headers=_HEADERS)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["server"] == "notebooklm-server"
|
|
assert body["version"]
|
|
# server_info deliberately probes ON-DISK storage (has_env_auth=False), so
|
|
# authenticated/sid_cookie are True only when a storage_state.json exists —
|
|
# not under the nightly's inline NOTEBOOKLM_AUTH_JSON auth. Assert the block
|
|
# shape, not the storage-dependent values (the data routes below prove the
|
|
# injected client is genuinely authenticated).
|
|
auth = body["auth"]
|
|
assert isinstance(auth["authenticated"], bool)
|
|
assert isinstance(auth["sid_cookie"], bool)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.readonly
|
|
async def test_list_notebooks_contains_readonly(
|
|
self, client: Any, read_only_notebook_id: str
|
|
) -> None:
|
|
"""``GET /v1/notebooks`` lists the live set including the read-only notebook."""
|
|
async with _live_rest_app(client) as http:
|
|
resp = await http.get("/v1/notebooks", headers=_HEADERS)
|
|
assert resp.status_code == 200
|
|
notebooks = resp.json()["notebooks"]
|
|
assert isinstance(notebooks, list) and notebooks
|
|
assert any(nb.get("id") == read_only_notebook_id for nb in notebooks)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.readonly
|
|
async def test_get_notebook(self, client: Any, read_only_notebook_id: str) -> None:
|
|
"""``GET /v1/notebooks/{id}`` resolves the read-only notebook by id."""
|
|
async with _live_rest_app(client) as http:
|
|
resp = await http.get(f"/v1/notebooks/{read_only_notebook_id}", headers=_HEADERS)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["id"] == read_only_notebook_id
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.readonly
|
|
async def test_list_sources_nonempty(self, client: Any, read_only_notebook_id: str) -> None:
|
|
"""``GET /v1/notebooks/{id}/sources`` returns the notebook's sources."""
|
|
async with _live_rest_app(client) as http:
|
|
resp = await http.get(
|
|
f"/v1/notebooks/{read_only_notebook_id}/sources", headers=_HEADERS
|
|
)
|
|
assert resp.status_code == 200
|
|
sources = resp.json()["sources"]
|
|
assert isinstance(sources, list) and sources
|
|
assert all(s.get("id") for s in sources)
|
|
|
|
|
|
@requires_auth
|
|
@pytest.mark.live_chat_ask
|
|
class TestRestServerLiveChat:
|
|
"""A live chat answer over the REST POST route."""
|
|
|
|
# @pytest.mark.readonly: chat.ask only appends ephemeral conversation history
|
|
# — no source, artifact, or note is mutated — so it is safe against the shared
|
|
# read-only notebook, matching the MCP TestMcpChat.test_configure_then_ask
|
|
# convention (also @readonly with the same fixture).
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.readonly
|
|
async def test_chat_ask_returns_answer(self, client: Any, read_only_notebook_id: str) -> None:
|
|
"""``POST /v1/notebooks/{id}/chat`` returns a non-empty answer string."""
|
|
async with _live_rest_app(client) as http:
|
|
try:
|
|
resp = await http.post(
|
|
f"/v1/notebooks/{read_only_notebook_id}/chat",
|
|
headers=_HEADERS,
|
|
json={"question": "In one sentence, what are these sources about?"},
|
|
)
|
|
except BaseException as exc: # noqa: BLE001 - re-raised unless rate-limited
|
|
# The conftest wraps client.chat.ask to pytest.skip() on a
|
|
# rate-limit ChatError. That works when a test calls ask()
|
|
# directly (the MCP chat e2e does), but here ask() runs *inside*
|
|
# the ASGI route, so the skip surfaces as a BaseExceptionGroup at
|
|
# the ASGITransport boundary instead of a clean skip. Honor the
|
|
# rate-limit skip here at the test frame; re-raise anything else.
|
|
rendered = "".join(traceback.format_exception(exc)).lower()
|
|
if any(p in rendered for p in _RATE_LIMIT_PHRASES):
|
|
pytest.skip("chat rate-limited (surfaced through the REST route)")
|
|
raise
|
|
assert resp.status_code == 200, resp.text
|
|
answer = resp.json()["answer"]
|
|
assert isinstance(answer, str) and answer.strip()
|