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

789 lines
33 KiB
Python

"""Tests for auth cookie/token extraction and AuthTokens dataclass (split in D1 PR-2).
This file owns one concern from the auth subpackage. The original
monolithic auth test module was split into six concern-aligned files
alongside the deletion of ``_AuthFacadeModule``; see ADR-0003
(superseded) and ADR-0007 (test-monkeypatch policy) for the rationale.
"""
import json
import pytest
from notebooklm._auth.cookies import load_httpx_cookies
from notebooklm._auth.extraction import (
_safe_url,
extract_csrf_from_html,
extract_session_id_from_html,
)
from notebooklm._auth.storage import save_cookies_to_storage, snapshot_cookie_jar
from notebooklm.auth import (
AuthTokens,
build_httpx_cookies_from_storage,
extract_cookies_from_storage,
)
class TestAuthTokens:
def test_dataclass_fields(self):
"""Test AuthTokens has required fields."""
tokens = AuthTokens(
cookies={"SID": "abc", "__Secure-1PSIDTS": "test_1psidts", "HSID": "def"},
csrf_token="csrf123",
session_id="sess456",
)
assert tokens.cookies == {
("SID", ".google.com", "/"): "abc",
("__Secure-1PSIDTS", ".google.com", "/"): "test_1psidts",
("HSID", ".google.com", "/"): "def",
}
assert tokens.flat_cookies == {
"SID": "abc",
"__Secure-1PSIDTS": "test_1psidts",
"HSID": "def",
}
assert tokens.csrf_token == "csrf123"
assert tokens.session_id == "sess456"
def test_cookie_header(self):
"""Test generating cookie header string."""
tokens = AuthTokens(
cookies={"SID": "abc", "__Secure-1PSIDTS": "test_1psidts", "HSID": "def"},
csrf_token="csrf123",
session_id="sess456",
)
header = tokens.cookie_header
assert "SID=abc" in header
assert "__Secure-1PSIDTS=test_1psidts" in header
assert "HSID=def" in header
def test_cookie_header_format(self):
"""Test cookie header uses semicolon separator."""
tokens = AuthTokens(
cookies={"A": "1", "B": "2"},
csrf_token="x",
session_id="y",
)
header = tokens.cookie_header
assert "; " in header
class TestExtractCookies:
def test_extracts_all_google_domain_cookies(self):
storage_state = {
"cookies": [
{"name": "SID", "value": "sid_value", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
{"name": "HSID", "value": "hsid_value", "domain": ".google.com"},
{
"name": "__Secure-1PSID",
"value": "secure_value",
"domain": ".google.com",
},
{
"name": "OSID",
"value": "osid_value",
"domain": "notebooklm.google.com",
},
{"name": "OTHER", "value": "other_value", "domain": "other.com"},
]
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["SID"] == "sid_value"
assert cookies["HSID"] == "hsid_value"
assert cookies["__Secure-1PSID"] == "secure_value"
assert cookies["OSID"] == "osid_value"
assert "OTHER" not in cookies
def test_extracts_osid_from_notebooklm_subdomain(self):
"""Test OSID extraction from .notebooklm.google.com (Issue #329)."""
storage_state = {
"cookies": [
{"name": "SID", "value": "sid_value", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
{
"name": "OSID",
"value": "osid_subdomain",
"domain": ".notebooklm.google.com",
},
{
"name": "__Secure-OSID",
"value": "secure_osid_subdomain",
"domain": ".notebooklm.google.com",
},
]
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["SID"] == "sid_value"
assert cookies["OSID"] == "osid_subdomain"
assert cookies["__Secure-OSID"] == "secure_osid_subdomain"
def test_prefers_base_domain_cookie_over_notebooklm_subdomain(self):
"""Test .google.com still wins duplicate names from NotebookLM subdomain."""
storage_state = {
"cookies": [
{
"name": "OSID",
"value": "osid_subdomain",
"domain": ".notebooklm.google.com",
},
{"name": "SID", "value": "sid_value", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
{"name": "OSID", "value": "osid_base", "domain": ".google.com"},
]
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["SID"] == "sid_value"
assert cookies["OSID"] == "osid_base"
@pytest.mark.parametrize(
"notebooklm_domain", [".notebooklm.google.com", "notebooklm.google.com"]
)
def test_prefers_notebooklm_subdomain_cookie_over_regional(self, notebooklm_domain):
"""Both NotebookLM subdomain forms win duplicate names from regional domains."""
storage_state = {
"cookies": [
{"name": "SID", "value": "sid_value", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
{"name": "OSID", "value": "osid_regional", "domain": ".google.de"},
{"name": "OSID", "value": "osid_subdomain", "domain": notebooklm_domain},
]
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["SID"] == "sid_value"
assert cookies["OSID"] == "osid_subdomain"
def test_prefers_dotted_notebooklm_over_no_dot_variant(self):
"""Playwright canonical (.notebooklm.google.com) wins over the no-dot form."""
storage_state = {
"cookies": [
{"name": "SID", "value": "sid_value", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
{"name": "OSID", "value": "osid_no_dot", "domain": "notebooklm.google.com"},
{"name": "OSID", "value": "osid_dotted", "domain": ".notebooklm.google.com"},
]
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["OSID"] == "osid_dotted"
# Reverse the duplicate pair (indices 2/3 — both OSID entries) so the
# no-dot variant precedes the dotted form in storage order. The dotted
# variant must still win deterministically. Earlier versions of this
# swap touched indices 1/2 which moved the ``__Secure-1PSIDTS``
# sentinel instead of flipping the OSID duplicates — that left the
# OSID order unchanged and silently weakened the regression.
storage_state["cookies"][2], storage_state["cookies"][3] = (
storage_state["cookies"][3],
storage_state["cookies"][2],
)
cookies = extract_cookies_from_storage(storage_state)
assert cookies["OSID"] == "osid_dotted"
def test_prefers_regional_over_googleusercontent(self):
"""Regional Google cookies win over .googleusercontent.com (priority 0)."""
storage_state = {
"cookies": [
{"name": "SID", "value": "sid_value", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
{"name": "X", "value": "x_uc", "domain": ".googleusercontent.com"},
{"name": "X", "value": "x_regional", "domain": ".google.de"},
]
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["X"] == "x_regional"
# Reverse the duplicate pair (indices 2/3 — both ``X`` entries) so the
# googleusercontent entry precedes the regional one. The regional
# cookie must still win deterministically. (See sibling-test comment
# for why indices 1/2 was the wrong swap.)
storage_state["cookies"][2], storage_state["cookies"][3] = (
storage_state["cookies"][3],
storage_state["cookies"][2],
)
cookies = extract_cookies_from_storage(storage_state)
assert cookies["X"] == "x_regional"
def test_first_google_com_duplicate_wins(self):
"""Within the .google.com tier, the first occurrence wins; later duplicates are ignored."""
storage_state = {
"cookies": [
{"name": "SID", "value": "first", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
{"name": "SID", "value": "second", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
]
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["SID"] == "first"
def test_raises_if_missing_sid(self):
storage_state = {
"cookies": [
{"name": "HSID", "value": "hsid_value", "domain": ".google.com"},
]
}
with pytest.raises(ValueError, match="Missing required cookies"):
extract_cookies_from_storage(storage_state)
def test_handles_empty_cookies_list(self):
"""Test handles empty cookies list."""
storage_state = {"cookies": []}
with pytest.raises(ValueError, match="Missing required cookies"):
extract_cookies_from_storage(storage_state)
def test_handles_missing_cookies_key(self):
"""Test handles missing cookies key."""
storage_state = {}
with pytest.raises(ValueError, match="Missing required cookies"):
extract_cookies_from_storage(storage_state)
class TestExtractCSRF:
def test_extracts_csrf_token(self):
"""Test extracting SNlM0e CSRF token from HTML."""
html = """
<script>window.WIZ_global_data = {
"SNlM0e": "AF1_QpN-xyz123",
"other": "value"
}</script>
"""
csrf = extract_csrf_from_html(html)
assert csrf == "AF1_QpN-xyz123"
def test_extracts_csrf_with_special_chars(self):
"""Test extracting CSRF token with special characters."""
html = '"SNlM0e":"AF1_QpN-abc_123/def"'
csrf = extract_csrf_from_html(html)
assert csrf == "AF1_QpN-abc_123/def"
def test_raises_if_not_found(self):
"""Test raises error if CSRF token not found."""
html = "<html><body>No token here</body></html>"
with pytest.raises(ValueError, match="CSRF token not found"):
extract_csrf_from_html(html)
def test_handles_empty_html(self):
"""Test handles empty HTML."""
with pytest.raises(ValueError, match="CSRF token not found"):
extract_csrf_from_html("")
class TestExtractSessionId:
def test_extracts_session_id(self):
"""Test extracting FdrFJe session ID from HTML."""
html = """
<script>window.WIZ_global_data = {
"FdrFJe": "session_id_abc",
"other": "value"
}</script>
"""
session_id = extract_session_id_from_html(html)
assert session_id == "session_id_abc"
def test_extracts_numeric_session_id(self):
"""Test extracting numeric session ID."""
html = '"FdrFJe":"1234567890123456"'
session_id = extract_session_id_from_html(html)
assert session_id == "1234567890123456"
def test_raises_if_not_found(self):
"""Test raises error if session ID not found."""
html = "<html><body>No session here</body></html>"
with pytest.raises(ValueError, match="Session ID not found"):
extract_session_id_from_html(html)
class TestCookieAttributePreservation:
"""Round-trip preservation of path, secure, and httpOnly across load+save (#365)."""
@staticmethod
def _find_cookie(jar, name, domain, path=None):
for cookie in jar.jar:
if cookie.name == name and cookie.domain == domain:
if path is None or cookie.path == path:
return cookie
raise AssertionError(f"cookie {name}@{domain} (path={path}) not in jar")
def _attr_storage_state(self):
"""Storage state with explicit non-default attributes on every cookie."""
return {
"cookies": [
{
"name": "SID",
"value": "sid-value",
"domain": ".google.com",
"path": "/u/0/",
"expires": 1893456000,
"httpOnly": True,
"secure": True,
"sameSite": "None",
},
{
"name": "__Secure-1PSIDTS",
"value": "test_1psidts",
"domain": ".google.com",
"path": "/",
"expires": 1893456000,
"httpOnly": True,
"secure": True,
"sameSite": "None",
},
{
"name": "__Host-GAPS",
"value": "host-only-value",
"domain": "accounts.google.com",
"path": "/",
"expires": -1,
"httpOnly": True,
"secure": True,
"sameSite": "Strict",
},
]
}
def test_load_httpx_cookies_preserves_attributes(self, tmp_path):
"""``load_httpx_cookies`` should carry path/secure/httpOnly into the jar."""
storage_file = tmp_path / "storage_state.json"
storage_file.write_text(json.dumps(self._attr_storage_state()))
jar = load_httpx_cookies(path=storage_file)
sid = self._find_cookie(jar, "SID", ".google.com")
assert sid.path == "/u/0/"
assert sid.secure is True
assert sid.has_nonstandard_attr("HttpOnly")
gaps = self._find_cookie(jar, "__Host-GAPS", "accounts.google.com")
assert gaps.path == "/"
assert gaps.secure is True
assert gaps.has_nonstandard_attr("HttpOnly")
def test_build_httpx_cookies_from_storage_preserves_attributes(self, tmp_path):
"""``build_httpx_cookies_from_storage`` should preserve the same attrs."""
storage_file = tmp_path / "storage_state.json"
storage_file.write_text(json.dumps(self._attr_storage_state()))
jar = build_httpx_cookies_from_storage(storage_file)
sid = self._find_cookie(jar, "SID", ".google.com")
assert sid.path == "/u/0/"
assert sid.secure is True
assert sid.has_nonstandard_attr("HttpOnly")
gaps = self._find_cookie(jar, "__Host-GAPS", "accounts.google.com")
assert gaps.path == "/"
assert gaps.secure is True
assert gaps.has_nonstandard_attr("HttpOnly")
def test_round_trip_with_value_change_preserves_attributes(self, tmp_path):
"""Load → bump value → save → reload preserves path/secure/httpOnly.
Mutating the value forces ``save_cookies_to_storage`` into the
"changed" branch that overwrites stored attrs from the live jar — the
path that previously eroded attributes to defaults.
"""
storage_file = tmp_path / "storage_state.json"
storage_file.write_text(json.dumps(self._attr_storage_state()))
jar = build_httpx_cookies_from_storage(storage_file)
snapshot = snapshot_cookie_jar(jar)
for cookie in jar.jar:
if cookie.name == "SID":
cookie.value = "rotated-sid"
save_cookies_to_storage(jar, storage_file, original_snapshot=snapshot)
on_disk = json.loads(storage_file.read_text())
sid_entry = next(c for c in on_disk["cookies"] if c["name"] == "SID")
assert sid_entry["path"] == "/u/0/"
assert sid_entry["secure"] is True
assert sid_entry["httpOnly"] is True
gaps_entry = next(c for c in on_disk["cookies"] if c["name"] == "__Host-GAPS")
assert gaps_entry["path"] == "/"
assert gaps_entry["secure"] is True
assert gaps_entry["httpOnly"] is True
def test_round_trip_without_value_change_preserves_attributes(self, tmp_path):
"""Load → save (no mutation) → reload preserves attrs.
This is the silent-erosion path users hit on idle calls: nothing
changes, but the save side appends fresh entries from the in-memory jar
(``notebooklm._auth.storage.save_cookies_to_storage``). Without the load-side fix, those appended entries
would carry default ``path=/``, ``secure=False``, ``httpOnly=False``.
"""
storage_file = tmp_path / "storage_state.json"
storage_file.write_text(json.dumps(self._attr_storage_state()))
jar = build_httpx_cookies_from_storage(storage_file)
save_cookies_to_storage(jar, storage_file, original_snapshot=snapshot_cookie_jar(jar))
reloaded = build_httpx_cookies_from_storage(storage_file)
sid = self._find_cookie(reloaded, "SID", ".google.com")
assert sid.path == "/u/0/"
assert sid.secure is True
assert sid.has_nonstandard_attr("HttpOnly")
def test_session_cookie_round_trips_as_minus_one(self, tmp_path):
"""Session cookies (expires=-1) survive without becoming a real timestamp."""
storage_file = tmp_path / "storage_state.json"
storage_file.write_text(json.dumps(self._attr_storage_state()))
jar = build_httpx_cookies_from_storage(storage_file)
gaps = self._find_cookie(jar, "__Host-GAPS", "accounts.google.com")
assert gaps.expires is None
snapshot = snapshot_cookie_jar(jar)
for cookie in jar.jar:
if cookie.name == "__Host-GAPS":
cookie.value = "rotated-gaps"
save_cookies_to_storage(jar, storage_file, original_snapshot=snapshot)
on_disk = json.loads(storage_file.read_text())
gaps_entry = next(c for c in on_disk["cookies"] if c["name"] == "__Host-GAPS")
assert gaps_entry["expires"] == -1
def test_expires_zero_round_trips(self, tmp_path):
"""``expires=0`` (Unix epoch) is a legitimate timestamp, not a sentinel.
Some Playwright variants emit ``0`` for cookies that expired at the
epoch. The load helper must distinguish ``0`` from ``-1`` / ``None``.
"""
state = {
"cookies": [
{
"name": "SID",
"value": "v",
"domain": ".google.com",
"path": "/",
"expires": 0,
"httpOnly": True,
"secure": True,
},
{
"name": "__Secure-1PSIDTS",
"value": "test_1psidts",
"domain": ".google.com",
"path": "/",
"expires": 1893456000,
"httpOnly": True,
"secure": True,
},
]
}
storage_file = tmp_path / "storage_state.json"
storage_file.write_text(json.dumps(state))
jar = build_httpx_cookies_from_storage(storage_file)
sid = self._find_cookie(jar, "SID", ".google.com")
# 0 is preserved as 0 — not collapsed to None (session) or -1.
assert sid.expires == 0
class TestFinalUrlScrubbing:
"""Auth-error messages must strip query + fragment from final_url interpolations.
Auth-handshake URLs frequently carry credential-shaped query params
(``f.sid=...``, ``continue=...``, ``access_token=...``). Without
sanitization these would leak into every drift-error message and the
associated log line.
"""
def test_final_url_stripped(self):
"""CSRF drift error must NOT include query params from final_url."""
# No WIZ_global_data → drift path; URL is not an accounts.google.com
# redirect so we get the "shape changed" raise that interpolates
# final_url into the message.
html = "<html><body>not a notebooklm page</body></html>"
final_url = "https://x.example/y?continue=foo&f.sid=bar#access_token=frag"
with pytest.raises(ValueError) as excinfo:
extract_csrf_from_html(html, final_url)
message = str(excinfo.value)
assert "continue=foo" not in message
assert "f.sid=bar" not in message
assert "access_token=frag" not in message
# The scheme/netloc/path triple should still appear so operators can
# identify the failing endpoint.
assert "https://x.example/y" in message
def test_final_url_stripped_session_id_path(self):
"""Session-ID drift error must NOT include query params from final_url."""
html = "<html><body>not a notebooklm page</body></html>"
final_url = "https://x.example/y?continue=foo&f.sid=bar#access_token=frag"
with pytest.raises(ValueError) as excinfo:
extract_session_id_from_html(html, final_url)
message = str(excinfo.value)
assert "continue=foo" not in message
assert "f.sid=bar" not in message
assert "access_token=frag" not in message
assert "https://x.example/y" in message
def test_final_url_stripped_userinfo(self):
"""URL userinfo (``https://TOKEN@host/...``) must NOT leak.
``urlparse(...).netloc`` preserves the userinfo component, so a naive
reconstruction from ``scheme://netloc/path`` would surface tokens
carried in the ``user[:password]@`` position. ``_safe_url`` rebuilds
from ``hostname`` + port instead.
"""
html = "<html><body>not a notebooklm page</body></html>"
# Token embedded as userinfo — the most adversarial leak vector.
final_url = "https://SECRET_TOKEN_USERINFO@x.example:8443/y?q=1"
with pytest.raises(ValueError) as excinfo:
extract_csrf_from_html(html, final_url)
message = str(excinfo.value)
assert "SECRET_TOKEN_USERINFO" not in message
# Port is preserved so operators can still identify the endpoint.
assert "https://x.example:8443/y" in message
class TestSafeUrlGoogleAuthHosts:
"""``_safe_url`` must drop the path component for Google OAuth hosts.
Background: Google's OAuth endpoints have historically embedded opaque
grant codes / tokens in the URL **path** (e.g. ``/o/oauth2/auth/<token>``
on ``accounts.google.com``). The original ``_safe_url`` stripped only
userinfo / query / fragment — a future redirect format change that put
a credential in the path segment would have leaked through
``ValueError("... Redirected to: %s" % final_url)`` (see
``_auth/refresh.py`` ``_fetch_tokens_with_jar``).
These tests pin the host-restricted path-redaction so non-auth endpoints
retain enough operator signal (host + path) to be diagnosable.
"""
def test_accounts_google_com_path_redacted(self):
"""accounts.google.com paths get replaced with /<redacted>."""
out = _safe_url("https://accounts.google.com/o/oauth2/auth/SECRET_GRANT_CODE?q=1")
assert "SECRET_GRANT_CODE" not in out
assert "/o/oauth2/auth" not in out
assert out == "https://accounts.google.com/<redacted>"
def test_oauth2_googleapis_com_path_redacted(self):
"""oauth2.googleapis.com paths get replaced with /<redacted>."""
out = _safe_url("https://oauth2.googleapis.com/token/SECRET_PATH_TOKEN")
assert "SECRET_PATH_TOKEN" not in out
assert out == "https://oauth2.googleapis.com/<redacted>"
def test_oauth2_googleusercontent_com_path_redacted(self):
"""oauth2.googleusercontent.com paths get replaced with /<redacted>."""
out = _safe_url("https://oauth2.googleusercontent.com/auth/SECRET_GRANT")
assert "SECRET_GRANT" not in out
assert out == "https://oauth2.googleusercontent.com/<redacted>"
def test_unrelated_googleusercontent_subdomain_path_preserved(self):
"""Non-``oauth2`` subdomains of googleusercontent.com keep their path.
The wider ``.googleusercontent.com`` family hosts artifact downloads
(slide decks, audio) — operator signal there is more useful than the
narrow leak risk. Only the ``oauth2`` subdomain is in the redact set.
"""
out = _safe_url("https://lh3.googleusercontent.com/a/AVATAR_PATH")
assert "AVATAR_PATH" in out, f"non-auth googleusercontent path lost: {out!r}"
def test_www_googleapis_com_path_preserved(self):
"""www.googleapis.com is NOT in the redact set (hosts many non-auth APIs)."""
out = _safe_url("https://www.googleapis.com/drive/v3/files/foo")
assert "/drive/v3/files/foo" in out
def test_accounts_google_com_root_path_preserved(self):
"""Root path ``/`` on Google auth hosts is NOT redacted (no signal to leak)."""
assert _safe_url("https://accounts.google.com/") == "https://accounts.google.com/"
def test_accounts_google_com_subdomain_path_redacted(self):
"""Subdomains of accounts.google.com also get path-redacted."""
out = _safe_url("https://x.accounts.google.com/o/oauth2/auth/TOKEN")
assert "TOKEN" not in out
assert out == "https://x.accounts.google.com/<redacted>"
def test_non_google_host_path_preserved(self):
"""Non-auth hosts keep their path so operators can identify endpoints."""
# Regression for ``test_final_url_stripped`` — non-Google host must
# round-trip the path through the safe-url pipe.
assert _safe_url("https://x.example/y") == "https://x.example/y"
def test_google_auth_host_case_insensitive(self):
"""Host comparison must be case-insensitive (RFC 3986 §3.2.2)."""
out = _safe_url("https://Accounts.Google.COM/o/oauth2/auth/TOK")
assert "TOK" not in out
# Hostname casing is preserved verbatim from ``urlparse(...).hostname``
# which lowercases the host; the assertion targets the absence of
# ``TOK`` regardless of host-casing rendering.
def test_google_auth_host_query_and_path_both_stripped(self):
"""Query is dropped by the existing pipe; path is dropped by the new
host-restricted rule. A URL with both must lose both."""
out = _safe_url(
"https://accounts.google.com/o/oauth2/auth/PATH_TOK?continue=https%3A%2F%2Fx.example"
)
assert "PATH_TOK" not in out
assert "continue=" not in out
assert out == "https://accounts.google.com/<redacted>"
def test_empty_url_still_empty(self):
"""Empty input degenerates cleanly (regression for the existing pipe)."""
assert _safe_url("") == ""
class TestExtractCookiesEdgeCases:
"""Test cookie extraction edge cases."""
def test_skips_cookies_without_name(self):
"""Test skips cookies without a name field."""
storage_state = {
"cookies": [
{"name": "SID", "value": "sid_value", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
{"value": "no_name_value", "domain": ".google.com"}, # Missing name
{"name": "", "value": "empty_name", "domain": ".google.com"}, # Empty name
]
}
cookies = extract_cookies_from_storage(storage_state)
assert "SID" in cookies
assert "__Secure-1PSIDTS" in cookies
# SID + __Secure-1PSIDTS extracted; nameless and empty-name entries skipped
assert len(cookies) == 2
def test_handles_cookie_with_empty_value(self):
"""Test handles cookies with empty values."""
storage_state = {
"cookies": [
{"name": "SID", "value": "", "domain": ".google.com"},
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
]
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["SID"] == ""
class TestExtractCSRFRedirect:
"""Test CSRF extraction redirect detection."""
def test_raises_on_redirect_to_accounts_in_url(self):
"""Test raises error when redirected to accounts.google.com (URL)."""
html = "<html><body>Login page</body></html>"
final_url = "https://accounts.google.com/signin"
with pytest.raises(ValueError, match="Authentication expired"):
extract_csrf_from_html(html, final_url)
def test_raises_on_redirect_to_accounts_in_html(self):
"""Test raises error when redirected to accounts.google.com (HTML content)."""
html = '<html><body><a href="https://accounts.google.com/signin">Sign in</a></body></html>'
with pytest.raises(ValueError, match="Authentication expired"):
extract_csrf_from_html(html)
class TestExtractSessionIdRedirect:
"""Test session ID extraction redirect detection."""
def test_raises_on_redirect_to_accounts_in_url(self):
"""Test raises error when redirected to accounts.google.com (URL)."""
html = "<html><body>Login page</body></html>"
final_url = "https://accounts.google.com/signin"
with pytest.raises(ValueError, match="Authentication expired"):
extract_session_id_from_html(html, final_url)
def test_raises_on_redirect_to_accounts_in_html(self):
"""Test raises error when redirected to accounts.google.com (HTML content)."""
html = '<html><body><a href="https://accounts.google.com/signin">Sign in</a></body></html>'
with pytest.raises(ValueError, match="Authentication expired"):
extract_session_id_from_html(html)
class TestUnavailableRedirectClassification:
"""A redirect to notebooklm.google is the region/anti-abuse gate, not a drift (#1630)."""
_MARKETING_HTML = "<html><body>NotebookLM marketing splash — no WIZ_global_data.</body></html>"
def test_csrf_classifies_location_unsupported_redirect(self):
with pytest.raises(ValueError) as exc:
extract_csrf_from_html(
self._MARKETING_HTML, "https://notebooklm.google/?location=unsupported"
)
msg = str(exc.value)
assert "region / anti-abuse access gate" in msg
assert "location=unsupported" in msg # the diagnostic is surfaced, not swallowed
assert "page structure" not in msg # NOT the misleading file-a-bug message
def test_session_id_classifies_redirect(self):
with pytest.raises(ValueError) as exc:
extract_session_id_from_html(self._MARKETING_HTML, "https://notebooklm.google")
msg = str(exc.value)
assert "region / anti-abuse access gate" in msg
assert "page structure" not in msg
def test_app_host_drift_still_says_page_structure(self):
# A token-less response from the real APP host (not the gate) keeps the
# original "page structure" message — the gate branch must not capture it.
with pytest.raises(ValueError, match="page structure"):
extract_csrf_from_html(self._MARKETING_HTML, "https://notebooklm.google.com/")
# The real notebooklm.google gate page carries an accounts.google.com sign-in
# link; the authoritative final-URL gate check must win over the body scan,
# otherwise it mis-routes to "Authentication expired" (the codex finding).
_GATE_HTML_WITH_SIGNIN = (
'<html><body>NotebookLM <a href="https://accounts.google.com/ServiceLogin">'
"Sign in</a></body></html>"
)
def test_csrf_gate_wins_over_accounts_link_in_body(self):
with pytest.raises(ValueError) as exc:
extract_csrf_from_html(
self._GATE_HTML_WITH_SIGNIN, "https://notebooklm.google/?location=unsupported"
)
msg = str(exc.value)
assert "region / anti-abuse access gate" in msg
assert "Authentication expired" not in msg
def test_session_id_gate_wins_over_accounts_link_in_body(self):
with pytest.raises(ValueError) as exc:
extract_session_id_from_html(
self._GATE_HTML_WITH_SIGNIN, "https://notebooklm.google/?location=unsupported"
)
msg = str(exc.value)
assert "region / anti-abuse access gate" in msg
assert "Authentication expired" not in msg
def test_gate_message_does_not_trigger_auto_refresh(self):
# An environmental gate must NOT match the auth-error signals that drive
# NOTEBOOKLM_REFRESH_CMD — re-auth can't fix it, so refreshing is futile.
# Pin it so a careless reword of the message can't silently re-enable it.
from notebooklm._auth.refresh import _AUTH_ERROR_SIGNALS
with pytest.raises(ValueError) as exc:
extract_csrf_from_html(
self._GATE_HTML_WITH_SIGNIN, "https://notebooklm.google/?location=unsupported"
)
lowered = str(exc.value).lower()
assert not any(signal in lowered for signal in _AUTH_ERROR_SIGNALS)