Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

415 lines
15 KiB
Python

"""Tests for the internal auth session refresh collaborator."""
from __future__ import annotations
import ast
import inspect
import textwrap
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
import pytest
from notebooklm._auth.session import refresh_auth_session
from notebooklm.auth import AuthTokens
from notebooklm.client import NotebookLMClient
from tests._fixtures.kernel_test_helpers import install_http_client_for_test
from tests._helpers.client_factory import build_client_shell_for_tests
REFRESH_HTML = '"SNlM0e":"new_csrf_token_123" "FdrFJe":"new_session_id_456"'
def _auth(**overrides: object) -> AuthTokens:
values = {
"cookies": {
"SID": "test_sid",
"__Secure-1PSIDTS": "test_1psidts",
"HSID": "test_hsid",
},
"csrf_token": "old_csrf",
"session_id": "old_session",
}
values.update(overrides)
return AuthTokens(**values)
class _RecordingKernel:
"""Stub kernel exposing :meth:`get_http_client`.
Wave 2 of plan ``host-protocol-removal`` narrowed
:func:`refresh_auth_session` to take ``kernel`` as an explicit
keyword-only argument; this stub mirrors the production
:class:`Kernel` shape that satisfies ``kernel.get_http_client()``
so tests can drive the refresh path without standing up the full
transport stack.
"""
def __init__(self, http_client: httpx.AsyncClient) -> None:
self._http_client = http_client
def get_http_client(self) -> httpx.AsyncClient:
return self._http_client
class _RecordingLifecycle:
"""Lifecycle stub matching the ``ClientLifecycle.save_cookies`` shape.
Wave 2 of plan ``host-protocol-removal`` narrowed
:meth:`ClientLifecycle.save_cookies` to take the
:class:`CookiePersistence` collaborator directly as its first
positional argument (was: a Session-shaped ``host``). The recorded
``cookie_persistence`` argument is forwarded as-is — for these unit
tests it's the bundle's own ``_RecordingCookiePersistence`` stub.
"""
def __init__(self) -> None:
self.operations: list[str] = []
self.saved_jars: list[httpx.Cookies] = []
async def save_cookies(
self,
cookie_persistence: Any,
jar: httpx.Cookies,
path: Path | None = None,
) -> None:
assert path is None
self.operations.append("save_cookies")
self.saved_jars.append(jar)
class _RecordingAuthCoord:
"""Coordinator stub recording ``update_auth_tokens`` /
``update_auth_headers`` calls.
Wave 2 of plan ``host-protocol-removal`` lifted
:func:`refresh_auth_session` off the legacy Session-shaped ``core``
Protocol; the function now invokes
``auth_coord.update_auth_tokens(auth=..., csrf=..., session_id=...)``
and ``auth_coord.update_auth_headers(auth=..., kernel=...)``
directly. The recording stub mirrors those new keyword-only
signatures and routes the token mutation through the shared
``AuthTokens`` instance, so post-refresh assertions on
``auth.csrf_token`` / ``.session_id`` still work.
"""
def __init__(self, operations: list[str]) -> None:
self._operations = operations
async def update_auth_tokens(self, *, auth: AuthTokens, csrf: str, session_id: str) -> None:
self._operations.append("update_auth_tokens")
auth.csrf_token = csrf
auth.session_id = session_id
def update_auth_headers(self, *, auth: AuthTokens, kernel: Any) -> None:
self._operations.append("update_auth_headers")
@dataclass
class RecordingRefreshBundle:
"""Aggregates the five collaborators :func:`refresh_auth_session`
now takes, with a shared operations log so tests can assert
end-to-end ordering across the auth-coord and lifecycle stubs.
Wave 2 of plan ``host-protocol-removal`` decoupled
:func:`refresh_auth_session` from a Session-shaped core; this
bundle replaces the prior ``RecordingRefreshCore`` shell with a
typed collection of the new explicit kwargs.
"""
auth: AuthTokens
http_client: httpx.AsyncClient
operations: list[str] = field(default_factory=list)
lifecycle: _RecordingLifecycle = field(default_factory=_RecordingLifecycle)
def __post_init__(self) -> None:
self.kernel = _RecordingKernel(self.http_client)
# Share storage so the legacy ordering
# (``[..., "save_cookies"]``) still resolves through a single
# log without re-aggregating two test-side lists.
self.lifecycle.operations = self.operations
self.auth_coord = _RecordingAuthCoord(self.operations)
# ``refresh_auth_session`` invokes
# ``lifecycle.save_cookies(cookie_persistence, jar)`` — the
# stub forwards the collaborator unchanged into its operations
# log, so a sentinel object is enough here.
self.cookie_persistence: Any = object()
@property
def saved_jars(self) -> list[httpx.Cookies]:
"""Back-compat passthrough so existing assertions still read the recorded jars."""
return self.lifecycle.saved_jars
def _client(handler: httpx.MockTransport | httpx.AsyncBaseTransport) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=handler, follow_redirects=True)
def _invoke(bundle: RecordingRefreshBundle):
"""Forward a :class:`RecordingRefreshBundle` into the new
:func:`refresh_auth_session` kwarg shape.
Wave 2 of plan ``host-protocol-removal`` made the five
collaborators (``auth`` / ``kernel`` / ``auth_coord`` /
``lifecycle`` / ``cookie_persistence``) keyword-only on the
refresh entry point; this helper keeps the per-test call sites a
single readable expression.
"""
return refresh_auth_session(
auth=bundle.auth,
kernel=bundle.kernel, # type: ignore[arg-type]
auth_coord=bundle.auth_coord, # type: ignore[arg-type]
lifecycle=bundle.lifecycle, # type: ignore[arg-type]
cookie_persistence=bundle.cookie_persistence,
)
@pytest.mark.asyncio
async def test_refresh_auth_session_default_account_uses_bare_base_url() -> None:
requests: list[httpx.URL] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request.url)
return httpx.Response(200, text=REFRESH_HTML, request=request)
async with _client(httpx.MockTransport(handler)) as http_client:
bundle = RecordingRefreshBundle(_auth(), http_client)
refreshed_auth = await _invoke(bundle)
assert requests == [httpx.URL("https://notebooklm.google.com/")]
assert refreshed_auth is bundle.auth
assert refreshed_auth.csrf_token == "new_csrf_token_123"
assert refreshed_auth.session_id == "new_session_id_456"
assert bundle.operations == ["update_auth_tokens", "update_auth_headers", "save_cookies"]
assert bundle.saved_jars == [http_client.cookies]
@pytest.mark.asyncio
async def test_refresh_auth_session_selected_account_uses_account_email_url() -> None:
requests: list[httpx.URL] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request.url)
return httpx.Response(200, text=REFRESH_HTML, request=request)
auth = _auth(authuser=2, account_email="bob@example.com")
async with _client(httpx.MockTransport(handler)) as http_client:
bundle = RecordingRefreshBundle(auth, http_client)
await _invoke(bundle)
assert requests == [httpx.URL("https://notebooklm.google.com/?authuser=bob%40example.com")]
@pytest.mark.asyncio
async def test_refresh_auth_session_selected_account_uses_authuser_url() -> None:
requests: list[httpx.URL] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request.url)
return httpx.Response(200, text=REFRESH_HTML, request=request)
auth = _auth(authuser=2)
async with _client(httpx.MockTransport(handler)) as http_client:
bundle = RecordingRefreshBundle(auth, http_client)
await _invoke(bundle)
assert requests == [httpx.URL("https://notebooklm.google.com/?authuser=2")]
@pytest.mark.asyncio
async def test_refresh_auth_session_detects_login_redirect() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "notebooklm.google.com":
return httpx.Response(
302,
headers={"Location": "https://accounts.google.com/signin/v2/identifier"},
request=request,
)
return httpx.Response(200, text="<html>Please sign in</html>", request=request)
async with _client(httpx.MockTransport(handler)) as http_client:
bundle = RecordingRefreshBundle(_auth(), http_client)
with pytest.raises(ValueError, match="Authentication expired"):
await _invoke(bundle)
assert bundle.operations == []
@pytest.mark.asyncio
async def test_refresh_auth_session_missing_csrf_wraps_extraction_error() -> None:
html = '\n <html>\n "FdrFJe":"session_only"\n </html>\n'
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=html, request=request)
async with _client(httpx.MockTransport(handler)) as http_client:
bundle = RecordingRefreshBundle(_auth(), http_client)
with pytest.raises(ValueError) as exc_info:
await _invoke(bundle)
message = str(exc_info.value)
assert "Failed to extract CSRF token (SNlM0e)." in message
assert "Preview:" in message
assert "\n" not in message
assert bundle.operations == []
@pytest.mark.asyncio
async def test_refresh_auth_session_missing_session_id_wraps_extraction_error() -> None:
html = '\n <html>\n "SNlM0e":"csrf_only"\n </html>\n'
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=html, request=request)
async with _client(httpx.MockTransport(handler)) as http_client:
bundle = RecordingRefreshBundle(_auth(), http_client)
with pytest.raises(ValueError) as exc_info:
await _invoke(bundle)
message = str(exc_info.value)
assert "Failed to extract session ID (FdrFJe)." in message
assert "Preview:" in message
assert "\n" not in message
assert bundle.operations == []
@pytest.mark.asyncio
async def test_refresh_auth_session_persists_through_client_core_save_cookies(
tmp_path: Path,
) -> None:
storage_path = tmp_path / "storage_state.json"
auth = _auth(storage_path=storage_path)
calls: list[tuple[Path, bool, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
text=REFRESH_HTML,
headers={"Set-Cookie": "SID=fresh_sid; Domain=.google.com; Path=/"},
request=request,
)
def fake_save_cookies_to_storage(
jar: httpx.Cookies,
path: Path,
*,
original_snapshot: object = None,
return_result: bool = False,
) -> bool:
assert jar.get("SID") == "fresh_sid"
calls.append((path, return_result, original_snapshot))
return True
# Inject the cookie-saver seam directly (Phase 2 PR 4 — replaces the
# legacy ``_core.save_cookies_to_storage`` string-target monkeypatch
# with constructor injection through ``ClientLifecycle._cookie_saver``).
core = build_client_shell_for_tests(auth, cookie_saver=fake_save_cookies_to_storage)
http_client = httpx.AsyncClient(
transport=httpx.MockTransport(handler),
cookies=auth.cookie_jar,
follow_redirects=True,
)
install_http_client_for_test(core._collaborators.kernel, http_client)
core._collaborators.cookie_persistence.capture_open_snapshot(http_client.cookies)
try:
# Wave 2 of plan ``host-protocol-removal`` made every dependency
# of :func:`refresh_auth_session` an explicit keyword-only
# collaborator. Drive the real client collaborators through the
# new entry point so the persistence path goes through the
# production ``ClientLifecycle.save_cookies`` → ``CookiePersistence``
# → ``asyncio.to_thread(fake_save_cookies_to_storage)`` plumbing.
await refresh_auth_session(
auth=core._auth,
kernel=core._collaborators.kernel,
auth_coord=core._collaborators.auth_coord,
lifecycle=core._collaborators.lifecycle,
cookie_persistence=core._collaborators.cookie_persistence,
)
finally:
await http_client.aclose()
install_http_client_for_test(core._collaborators.kernel, None)
assert len(calls) == 1
path, return_result, original_snapshot = calls[0]
assert path == storage_path
assert return_result is True
assert original_snapshot is not None
def test_client_refresh_auth_is_facade_only() -> None:
source = textwrap.dedent(inspect.getsource(NotebookLMClient.refresh_auth))
tree = ast.parse(source)
function = next(node for node in ast.walk(tree) if isinstance(node, ast.AsyncFunctionDef))
calls_refresh_session = any(
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "refresh_auth_session"
for node in ast.walk(function)
)
forbidden_names = {
"extract_wiz_field",
"is_google_auth_redirect",
"_get_auth_snapshot_lock",
}
forbidden_attrs = {
"update_auth_tokens",
"update_auth_headers",
"save_cookies",
"csrf_token",
"session_id",
}
violations: list[tuple[int, str]] = []
for node in ast.walk(function):
if isinstance(node, ast.Name) and node.id in forbidden_names:
violations.append((node.lineno, node.id))
elif isinstance(node, ast.Attribute) and node.attr in forbidden_attrs:
violations.append((node.lineno, node.attr))
assert calls_refresh_session
assert violations == []
def test_auth_session_has_no_runtime_class_imports() -> None:
"""``_auth/session.py`` must not import ``NotebookLMClient`` or ``Session``.
Module-level guards against importing ``notebooklm.client`` /
``notebooklm._core`` modules live in
``tests/_guardrails/test_no_core_imports.py``; this test covers the
type-name axis (import the *class* by name from anywhere), which the
module-level lint can't see.
"""
path = Path(__file__).parents[2] / "src/notebooklm/_auth/session.py"
tree = ast.parse(path.read_text())
parents: dict[ast.AST, ast.AST] = {}
for parent in ast.walk(tree):
for child in ast.iter_child_nodes(parent):
parents[child] = parent
def inside_type_checking(node: ast.AST) -> bool:
while node in parents:
node = parents[node]
if isinstance(node, ast.If) and ast.unparse(node.test) == "TYPE_CHECKING":
return True
return False
forbidden_type_names = {"NotebookLMClient", "Session"}
violations: list[tuple[int, str]] = []
for node in ast.walk(tree):
if inside_type_checking(node):
continue
if isinstance(node, ast.ImportFrom):
imported_names = {alias.name for alias in node.names}
for name in sorted(imported_names & forbidden_type_names):
violations.append((node.lineno, name))
assert violations == []