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

174 lines
6.7 KiB
Python

"""Unit tests for _auth/master_token.py (headless master-token minting).
No network: gpsoauth is faked via sys.modules; the OAuthLogin/MergeSession GETs
are intercepted with pytest-httpx's httpx_mock.
"""
from __future__ import annotations
import json
import re
import sys
import types
import httpx
import pytest
from notebooklm._auth import master_token as mt
from notebooklm._auth.master_token import MasterTokenError
_OAUTHLOGIN_RE = re.compile(r"^https://accounts\.google\.com/OAuthLogin")
_MERGESESSION_RE = re.compile(r"^https://accounts\.google\.com/MergeSession")
_ROTATE_RE = re.compile(r"^https://accounts\.google\.com/RotateCookies")
@pytest.fixture
def fake_gpsoauth(monkeypatch):
"""Install a fake `gpsoauth` module; tests set .exchange/.oauth return values."""
mod = types.ModuleType("gpsoauth")
mod.exchange_return = {"Token": "aas_et/MASTER"}
mod.oauth_return = {"Auth": "ya29.FAKE"}
mod.exchange_token = lambda email, oauth_token, android_id: mod.exchange_return
mod.perform_oauth = lambda *a, **k: mod.oauth_return
monkeypatch.setitem(sys.modules, "gpsoauth", mod)
return mod
def _merge_session_cookies(*names: str) -> list[tuple[str, str]]:
return [("set-cookie", f"{n}=val-{n}; Domain=.google.com; Path=/; Secure") for n in names]
# --- exchange_master_token -------------------------------------------------
def test_exchange_master_token_success(fake_gpsoauth):
assert mt.exchange_master_token("e@x.com", "oauth_token", "abc") == "aas_et/MASTER"
def test_exchange_master_token_rejected(fake_gpsoauth):
fake_gpsoauth.exchange_return = {"Error": "BadAuthentication"}
with pytest.raises(MasterTokenError, match="oauth_token"):
mt.exchange_master_token("e@x.com", "stale", "abc")
def test_exchange_master_token_no_secret_in_error(fake_gpsoauth):
fake_gpsoauth.exchange_return = {"Error": "BadAuthentication"}
with pytest.raises(MasterTokenError) as exc:
mt.exchange_master_token("e@x.com", "SUPERSECRET", "abc")
assert "SUPERSECRET" not in str(exc.value)
# --- mint_cookies ----------------------------------------------------------
@pytest.mark.no_default_keepalive_mock # own the RotateCookies response (mint PSIDTS)
@pytest.mark.asyncio
async def test_mint_cookies_success_and_call_order(fake_gpsoauth, httpx_mock):
httpx_mock.add_response(url=_OAUTHLOGIN_RE, text="APh-UBERAUTH")
httpx_mock.add_response(
url=_MERGESESSION_RE,
headers=_merge_session_cookies("SID", "APISID", "SAPISID", "HSID"),
)
# RotateCookies mints __Secure-1PSIDTS into the same jar so it's complete.
httpx_mock.add_response(
url=_ROTATE_RE,
headers=_merge_session_cookies("__Secure-1PSIDTS"),
)
jar = await mt.mint_cookies("e@x.com", "aas_et/MASTER", "abc")
names = {c.name for c in jar.jar}
assert {"SID", "APISID", "SAPISID", "__Secure-1PSIDTS"} <= names
# OAuthLogin -> MergeSession -> RotateCookies (POST), in order.
reqs = httpx_mock.get_requests()
assert "OAuthLogin" in str(reqs[0].url)
assert "MergeSession" in str(reqs[1].url)
assert "RotateCookies" in str(reqs[2].url) and reqs[2].method == "POST"
# uberauth was forwarded to MergeSession.
assert "APh-UBERAUTH" in str(reqs[1].url)
@pytest.mark.asyncio
async def test_mint_cookies_revoked_master_token(fake_gpsoauth, httpx_mock):
fake_gpsoauth.oauth_return = {"Error": "BadAuthentication"}
with pytest.raises(MasterTokenError, match="re-bootstrap|master token"):
await mt.mint_cookies("e@x.com", "revoked", "abc")
@pytest.mark.asyncio
async def test_mint_cookies_no_uberauth(fake_gpsoauth, httpx_mock):
httpx_mock.add_response(url=_OAUTHLOGIN_RE, text="")
with pytest.raises(MasterTokenError, match="uberauth"):
await mt.mint_cookies("e@x.com", "aas_et/MASTER", "abc")
@pytest.mark.asyncio
async def test_mint_cookies_missing_required_cookie(fake_gpsoauth, httpx_mock):
httpx_mock.add_response(url=_OAUTHLOGIN_RE, text="APh-X")
# MergeSession returns SID but no secondary binding (APISID/SAPISID).
httpx_mock.add_response(
url=_MERGESESSION_RE,
headers=_merge_session_cookies("SID"),
)
# RotateCookies still fires after MergeSession; the autouse keepalive mock
# answers it, so the required-cookie check is what raises.
with pytest.raises(MasterTokenError, match="missing required cookies"):
await mt.mint_cookies("e@x.com", "aas_et/MASTER", "abc")
# --- storage_state_from_jar -----------------------------------------------
def test_storage_state_shape_and_namespace():
jar = httpx.Cookies()
jar.set("SID", "v", domain=".google.com", path="/")
state = mt.storage_state_from_jar(jar, email="e@x.com")
assert state["origins"] == []
sid = next(c for c in state["cookies"] if c["name"] == "SID")
assert sid["domain"] == ".google.com" and "secure" in sid and "httpOnly" in sid
# notebooklm namespace carries version + account (read by account.py).
assert state["notebooklm"] == {"version": 1, "account": {"authuser": 0, "email": "e@x.com"}}
# --- master_token.json persistence ----------------------------------------
def test_write_read_master_token_roundtrip_0600(tmp_path):
path = tmp_path / "master_token.json"
mt.write_master_token(path, email="e@x.com", master_token="aas_et/M", android_id="abc")
if sys.platform != "win32": # Windows ignores POSIX mode bits
assert (path.stat().st_mode & 0o777) == 0o600
rec = mt.read_master_token(path)
assert rec["master_token"] == "aas_et/M" and rec["android_id"] == "abc"
def test_read_master_token_absent(tmp_path):
assert mt.read_master_token(tmp_path / "nope.json") is None
def test_read_master_token_malformed(tmp_path):
path = tmp_path / "master_token.json"
path.write_text(json.dumps({"version": 99}))
with pytest.raises(MasterTokenError, match="malformed|version"):
mt.read_master_token(path)
def test_read_master_token_non_dict_json(tmp_path):
# A bare JSON array must raise MasterTokenError, not AttributeError on .get.
path = tmp_path / "master_token.json"
path.write_text(json.dumps([]))
with pytest.raises(MasterTokenError, match="malformed|version"):
mt.read_master_token(path)
def test_generate_android_id_is_16_hex():
aid = mt.generate_android_id()
assert len(aid) == 16 and int(aid, 16) >= 0 and aid != mt.generate_android_id()
# --- missing optional dependency ------------------------------------------
def test_missing_gpsoauth_raises_actionable(monkeypatch):
monkeypatch.setitem(sys.modules, "gpsoauth", None) # `import gpsoauth` -> ImportError
with pytest.raises(MasterTokenError, match=r"notebooklm-py\[headless\]"):
mt.exchange_master_token("e@x.com", "tok", "abc")