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

475 lines
19 KiB
Python

"""Tests for ``notebooklm._app.auth_check`` — the ``auth check`` diagnostics core.
Covers :func:`run_auth_check` over the probe matrix:
* storage-exists (file vs. inline env-auth),
* JSON-valid (file read errors + env-JSON decode errors via the injected reader),
* cookies-present + SID-cookie lookup,
* the optional ``--test`` token-fetch round-trip (patched at ``notebooklm.auth``),
* the ``AuthCheckResult.all_passed`` rollup (``None`` = not-tested is ignored).
Direct ``_app`` calls only — :class:`AuthCheckPlan` built inline + the
``read_env_auth_json`` reader injected as a plain callable, no Click / CliRunner.
The real :func:`notebooklm.auth.extract_cookies_from_storage` runs against
hand-built ``storage_state`` dicts so the cookie/SID probes exercise production
parsing.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
import notebooklm.auth as auth_module
from notebooklm._app.auth_check import (
AuthCheckPlan,
AuthCheckResult,
run_auth_check,
)
def _plan(
*,
storage_path: Path,
has_env_auth: bool = False,
has_home_env: bool = False,
profile: str | None = "default",
test_fetch: bool = False,
json_output: bool = False,
) -> AuthCheckPlan:
return AuthCheckPlan(
storage_path=storage_path,
profile=profile,
has_env_auth=has_env_auth,
has_home_env=has_home_env,
auth_source_label="file (storage_state.json)",
test_fetch=test_fetch,
json_output=json_output,
)
#: The cookies ``extract_cookies_from_storage`` requires, or it raises
#: ``ValueError`` (which the auth-check core maps to ``cookies_present=False``).
_REQUIRED_COOKIES = ("SID", "__Secure-1PSIDTS")
def _storage_state(*cookie_names: str, domain: str = ".google.com") -> dict[str, Any]:
"""A Playwright storage_state with the given Google cookies."""
return {
"cookies": [
{"name": name, "value": f"{name}-val", "domain": domain, "path": "/"}
for name in cookie_names
]
}
def _valid_storage_state(*extra: str, domain: str = ".google.com") -> dict[str, Any]:
"""A storage_state that satisfies ``extract_cookies_from_storage`` (SID + 1PSIDTS)."""
return _storage_state(*_REQUIRED_COOKIES, *extra, domain=domain)
def _never_read_env() -> str: # pragma: no cover - guard for non-env paths
raise AssertionError("read_env_auth_json must not be called when has_env_auth is False")
# ---------------------------------------------------------------------------
# Check 1: storage exists
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_missing_storage_file_fails_first_check(tmp_path: Path) -> None:
plan = _plan(storage_path=tmp_path / "missing.json")
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert isinstance(result, AuthCheckResult)
assert result.checks["storage_exists"] is False
assert result.checks["json_valid"] is False
assert result.all_passed is False
assert "Storage file not found" in result.details["error"]
# The plan-resolved auth-source label is echoed into details.
assert result.details["auth_source"] == "file (storage_state.json)"
@pytest.mark.asyncio
async def test_env_auth_treats_storage_as_present(tmp_path: Path) -> None:
"""With env-auth active, storage-exists is True without touching the file."""
state = _valid_storage_state("HSID")
plan = _plan(storage_path=tmp_path / "ignored.json", has_env_auth=True)
result = await run_auth_check(plan, read_env_auth_json=lambda: json.dumps(state))
assert result.checks["storage_exists"] is True
assert result.checks["json_valid"] is True
assert result.checks["cookies_present"] is True
assert result.checks["sid_cookie"] is True
assert result.all_passed is True
# ---------------------------------------------------------------------------
# Check 2: JSON valid
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_invalid_json_on_disk_fails_json_check(tmp_path: Path) -> None:
storage = tmp_path / "storage_state.json"
storage.write_text("{not json", encoding="utf-8")
plan = _plan(storage_path=storage)
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert result.checks["storage_exists"] is True
assert result.checks["json_valid"] is False
assert result.checks["cookies_present"] is False
assert "Invalid JSON" in result.details["error"]
@pytest.mark.asyncio
async def test_env_auth_invalid_json_fails_json_check(tmp_path: Path) -> None:
"""An env-supplied payload that fails to decode → json_valid False, no KeyError."""
plan = _plan(storage_path=tmp_path / "ignored.json", has_env_auth=True)
result = await run_auth_check(plan, read_env_auth_json=lambda: "{not json")
assert result.checks["storage_exists"] is True
assert result.checks["json_valid"] is False
assert "Invalid JSON" in result.details["error"]
@pytest.mark.asyncio
async def test_storage_unreadable_oserror_maps_to_error(tmp_path: Path) -> None:
"""A directory at the storage path (OSError on read) → structured error."""
storage_dir = tmp_path / "storage_state.json"
storage_dir.mkdir() # reading a directory as text raises OSError
plan = _plan(storage_path=storage_dir)
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert result.checks["storage_exists"] is True
assert result.checks["json_valid"] is False
assert "Storage unreadable" in result.details["error"]
# ---------------------------------------------------------------------------
# Check 3: cookies present + SID lookup
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cookies_present_with_sid(tmp_path: Path) -> None:
storage = tmp_path / "storage_state.json"
storage.write_text(json.dumps(_valid_storage_state("HSID", "SSID")), encoding="utf-8")
plan = _plan(storage_path=storage)
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert result.checks["cookies_present"] is True
assert result.checks["sid_cookie"] is True
assert "SID" in result.details["cookies_found"]
# Google-domain grouping is surfaced for the renderer. ``cookie_domains`` is
# a list of exact domain keys; assert exact element membership (an explicit
# ``==`` per element, not a substring ``in``, so CodeQL's
# incomplete-url-substring-sanitization heuristic does not flag this test).
assert any(domain == ".google.com" for domain in result.details["cookie_domains"])
assert result.all_passed is True
@pytest.mark.asyncio
async def test_missing_required_cookies_fails_cookie_check(tmp_path: Path) -> None:
"""A storage without the required cookies → ``extract_cookies_from_storage``
raises ``ValueError``, which the core maps to cookies_present=False + error."""
storage = tmp_path / "storage_state.json"
# Only HSID/SSID present — missing the required SID + __Secure-1PSIDTS pair.
storage.write_text(json.dumps(_storage_state("HSID", "SSID")), encoding="utf-8")
plan = _plan(storage_path=storage)
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert result.checks["json_valid"] is True
assert result.checks["cookies_present"] is False
assert result.checks["sid_cookie"] is False
assert result.details["error"]
assert result.all_passed is False
# ---------------------------------------------------------------------------
# Check 4: optional token-fetch round-trip
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_token_fetch_success(tmp_path: Path) -> None:
storage = tmp_path / "storage_state.json"
storage.write_text(json.dumps(_valid_storage_state("HSID")), encoding="utf-8")
plan = _plan(storage_path=storage, test_fetch=True)
fetch = AsyncMock(return_value=("csrf-token-value", "session-id-value"))
# ``run_auth_check`` does ``from ..auth import fetch_tokens_with_domains``
# at call time, so patch the public facade name it resolves.
with patch.object(auth_module, "fetch_tokens_with_domains", fetch):
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert result.checks["token_fetch"] is True
assert result.details["csrf_length"] == len("csrf-token-value")
assert result.details["session_id_length"] == len("session-id-value")
assert result.all_passed is True
# File-based auth → the storage path is forwarded to the fetch.
fetch.assert_awaited_once_with(storage, "default")
@pytest.mark.asyncio
async def test_token_fetch_failure_sets_false(tmp_path: Path) -> None:
storage = tmp_path / "storage_state.json"
storage.write_text(json.dumps(_valid_storage_state()), encoding="utf-8")
plan = _plan(storage_path=storage, test_fetch=True)
fetch = AsyncMock(side_effect=RuntimeError("network down"))
with patch.object(auth_module, "fetch_tokens_with_domains", fetch):
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert result.checks["token_fetch"] is False
assert "Token fetch failed" in result.details["error"]
assert "network down" in result.details["error"]
assert result.all_passed is False
@pytest.mark.asyncio
async def test_token_fetch_env_auth_passes_none_path(tmp_path: Path) -> None:
"""Env-auth token-fetch forwards a ``None`` path (the env JSON is the source)."""
plan = _plan(
storage_path=tmp_path / "ignored.json",
has_env_auth=True,
profile="work",
test_fetch=True,
)
fetch = AsyncMock(return_value=("c", "s"))
with patch.object(auth_module, "fetch_tokens_with_domains", fetch):
result = await run_auth_check(
plan, read_env_auth_json=lambda: json.dumps(_valid_storage_state())
)
assert result.checks["token_fetch"] is True
fetch.assert_awaited_once_with(None, "work")
@pytest.mark.asyncio
async def test_token_fetch_not_run_when_test_fetch_false(tmp_path: Path) -> None:
"""Without ``--test`` the token_fetch check stays ``None`` (not tested)."""
storage = tmp_path / "storage_state.json"
storage.write_text(json.dumps(_valid_storage_state()), encoding="utf-8")
plan = _plan(storage_path=storage, test_fetch=False)
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert result.checks["token_fetch"] is None
# all_passed ignores the not-tested (None) token_fetch.
assert result.all_passed is True
# ---------------------------------------------------------------------------
# Identity + location facts (issue #1640)
# ---------------------------------------------------------------------------
def _storage_with_account(
*cookie_names: str, email: str | None = None, authuser: int = 0
) -> dict[str, Any]:
"""A valid storage_state carrying the in-band account namespace."""
state = _valid_storage_state(*cookie_names)
if email is not None:
state["notebooklm"] = {"account": {"email": email, "authuser": authuser}}
return state
@pytest.mark.asyncio
async def test_identity_fields_populated_from_storage(tmp_path: Path) -> None:
storage = tmp_path / "storage_state.json"
storage.write_text(
json.dumps(_storage_with_account("APISID", "SAPISID", email="you@gmail.com", authuser=2)),
encoding="utf-8",
)
plan = _plan(storage_path=storage, profile="github")
result = await run_auth_check(plan, read_env_auth_json=_never_read_env)
assert result.details["account"] == {"email": "you@gmail.com", "authuser": 2}
assert result.details["profile"] == "github"
assert result.details["storage_path"] == str(storage)
assert result.details["psidts"]["present"] is True
# No sibling master_token.json → reported absent with the path it looked for.
assert result.details["master_token"]["present"] is False
assert result.details["master_token"]["path"] == str(storage.with_name("master_token.json"))
@pytest.mark.asyncio
async def test_psidts_expiry_surfaced(tmp_path: Path) -> None:
storage = tmp_path / "storage_state.json"
storage.write_text(
json.dumps(
{
"cookies": [
{"name": "SID", "value": "v", "domain": ".google.com", "path": "/"},
{
"name": "__Secure-1PSIDTS",
"value": "v",
"domain": ".google.com",
"path": "/",
"expires": 1_800_000_000,
},
]
}
),
encoding="utf-8",
)
result = await run_auth_check(_plan(storage_path=storage), read_env_auth_json=_never_read_env)
psidts = result.details["psidts"]
assert psidts["present"] is True
# Epoch 1_800_000_000 → 2027-01-15T08:00:00+00:00 (UTC ISO).
assert psidts["expires_at"].startswith("2027-01-15T08:00:00")
@pytest.mark.asyncio
async def test_psidts_out_of_range_expiry_degrades_to_none(tmp_path: Path) -> None:
"""A corrupt/out-of-range ``expires`` must not abort the check (auth check
has no error envelope); the field degrades to present + expires_at=None."""
storage = tmp_path / "storage_state.json"
storage.write_text(
json.dumps(
{
"cookies": [
{"name": "SID", "value": "v", "domain": ".google.com", "path": "/"},
{
"name": "__Secure-1PSIDTS",
"value": "v",
"domain": ".google.com",
"path": "/",
"expires": 1e20, # OverflowError from datetime.fromtimestamp
},
]
}
),
encoding="utf-8",
)
result = await run_auth_check(_plan(storage_path=storage), read_env_auth_json=_never_read_env)
assert result.details["psidts"] == {"present": True, "expires_at": None}
@pytest.mark.asyncio
async def test_master_token_present_and_account_read(tmp_path: Path) -> None:
storage = tmp_path / "storage_state.json"
storage.write_text(json.dumps(_valid_storage_state("APISID", "SAPISID")), encoding="utf-8")
auth_module.write_master_token(
storage.with_name("master_token.json"),
email="owner@gmail.com",
master_token="aas_et/secret",
android_id="0123456789abcdef",
)
result = await run_auth_check(_plan(storage_path=storage), read_env_auth_json=_never_read_env)
mt = result.details["master_token"]
assert mt["present"] is True
assert mt["account"] == "owner@gmail.com"
assert mt["path"] == str(storage.with_name("master_token.json"))
@pytest.mark.asyncio
async def test_missing_psidts_with_master_token_uses_corrected_hint(tmp_path: Path) -> None:
"""A master-token profile missing PSIDTS at rest gets master-token guidance,
not the (wrong) browser-extraction / App-Bound Encryption hint."""
storage = tmp_path / "storage_state.json"
# SID + secondary binding present, but no __Secure-1PSIDTS (the recoverable,
# self-healing case for a master-token profile).
storage.write_text(json.dumps(_storage_state("SID", "APISID", "SAPISID")), encoding="utf-8")
auth_module.write_master_token(
storage.with_name("master_token.json"),
email="owner@gmail.com",
master_token="aas_et/secret",
android_id="0123456789abcdef",
)
result = await run_auth_check(_plan(storage_path=storage), read_env_auth_json=_never_read_env)
assert result.checks["cookies_present"] is False
assert "master_token.json is present" in result.details["error"]
assert "App-Bound Encryption" not in result.details["error"]
assert result.details["master_token"]["present"] is True
@pytest.mark.asyncio
async def test_env_auth_reads_account_from_inline_json(tmp_path: Path) -> None:
plan = _plan(
storage_path=tmp_path / "ignored.json",
has_env_auth=True,
profile="work",
)
inline = json.dumps(_storage_with_account(email="ci@gmail.com", authuser=1))
result = await run_auth_check(plan, read_env_auth_json=lambda: inline)
assert result.details["account"] == {"email": "ci@gmail.com", "authuser": 1}
# Env-auth has no profile directory → master-token is N/A.
assert result.details["master_token"] == {"present": False, "path": None, "account": None}
@pytest.mark.asyncio
async def test_env_auth_account_normalizes_whitespace_and_rejects_bool(tmp_path: Path) -> None:
"""Env-auth account mirrors the file path: whitespace-only email → None, and a
bool authuser (``bool`` is an ``int`` subclass) falls back to 0."""
plan = _plan(storage_path=tmp_path / "ignored.json", has_env_auth=True)
state = _valid_storage_state()
state["notebooklm"] = {"account": {"email": " ", "authuser": True}}
result = await run_auth_check(plan, read_env_auth_json=lambda: json.dumps(state))
assert result.details["account"] == {"email": None, "authuser": 0}
@pytest.mark.asyncio
async def test_missing_sid_and_psidts_keeps_generic_hint(tmp_path: Path) -> None:
"""When BOTH SID and PSIDTS are missing the session is unrecoverable, so the
master-token hint must NOT fire even with a sibling master_token.json."""
storage = tmp_path / "storage_state.json"
# Neither SID nor __Secure-1PSIDTS — only the secondary binding pair.
storage.write_text(json.dumps(_storage_state("APISID", "SAPISID")), encoding="utf-8")
auth_module.write_master_token(
storage.with_name("master_token.json"),
email="owner@gmail.com",
master_token="aas_et/secret",
android_id="0123456789abcdef",
)
result = await run_auth_check(_plan(storage_path=storage), read_env_auth_json=_never_read_env)
assert result.checks["cookies_present"] is False
assert "master_token.json is present" not in result.details["error"]
# ---------------------------------------------------------------------------
# all_passed rollup
# ---------------------------------------------------------------------------
def test_all_passed_ignores_none_but_fails_on_false() -> None:
plan = _plan(storage_path=Path("/x"))
passing = AuthCheckResult(
plan=plan,
checks={
"storage_exists": True,
"json_valid": True,
"cookies_present": True,
"sid_cookie": True,
"token_fetch": None,
},
)
assert passing.all_passed is True
failing = AuthCheckResult(
plan=plan,
checks={"storage_exists": True, "sid_cookie": False, "token_fetch": None},
)
assert failing.all_passed is False