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
227 lines
10 KiB
Python
227 lines
10 KiB
Python
"""CLI integration tests for ``notebooklm auth`` (VCR replay).
|
|
|
|
The ``auth`` group mixes local-only subcommands (``inspect`` / ``logout`` --
|
|
filesystem and browser-cookie only) with RPC-path subcommands:
|
|
|
|
* ``auth check`` -- local cookie validation by default (no HTTP); with
|
|
``--test`` it also exercises the token-fetch round-trip (a homepage GET to
|
|
re-mint CSRF / session).
|
|
* ``auth refresh`` -- the one-shot keepalive: loads the profile's
|
|
``storage_state.json``, fetches CSRF + session from ``notebooklm.google.com``
|
|
(a homepage GET; their side effect is the rotated cookie jar), and persists
|
|
the jar back to disk.
|
|
|
|
These tests drive the RPC-path subcommands through Click's ``CliRunner`` while
|
|
VCR replays recorded traffic from ``tests/cassettes`` -- no live auth, no new
|
|
recording. They close the ``auth`` cli_vcr coverage gap (issue #1452 Phase 3):
|
|
the gate's ``COVERAGE_EXEMPT`` reason for ``auth`` was stale -- the cassette the
|
|
token-fetch path needs (``auth_rotate_cookies_refresh.yaml``) already existed,
|
|
so the group is exercisable without a maintainer recording.
|
|
|
|
Cassettes reused:
|
|
|
|
* ``cli_auth.yaml`` -- intentionally empty (``interactions: []``). Pins the
|
|
*local* ``auth check`` path so that if a future refactor makes the no-network
|
|
validation start issuing HTTP, VCR's ``record_mode="none"`` trips a loud
|
|
``CannotOverwriteExistingCassetteException`` (the doctor / profile idiom).
|
|
* ``auth_rotate_cookies_refresh.yaml`` -- the recorded refresh handshake. Its
|
|
homepage ``GET https://notebooklm.google.com/`` (interaction 2) is exactly the
|
|
request the token-fetch path makes; the two ``wXbhsf`` batchexecute POSTs it
|
|
also carries are left unplayed (VCR tolerates unplayed cassette interactions,
|
|
erroring only on UNMATCHED requests). Cross-referenced against
|
|
``tests/integration/test_auth_refresh_vcr.py``, which drives the same cassette
|
|
through the client API.
|
|
|
|
Why ``auth refresh`` / ``auth check --test`` deliberately do NOT use
|
|
``mock_auth_for_vcr``: that fixture patches ``notebooklm.auth.fetch_tokens_with_domains``
|
|
to a stub, which would short-circuit the very token-fetch round-trip these tests
|
|
mean to replay. Instead they run the real fetch against an isolated profile with
|
|
a real ``storage_state.json`` so the homepage GET reaches the cassette. The
|
|
autouse ``_disable_keepalive_poke_for_vcr`` fixture (in
|
|
``tests/integration/conftest.py``) suppresses the layer-1 ``RotateCookies`` poke
|
|
for every ``@pytest.mark.vcr`` test, so no un-recorded ``accounts.google.com``
|
|
POST escapes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from notebooklm import paths
|
|
from notebooklm.notebooklm_cli import cli
|
|
|
|
from .conftest import notebooklm_vcr, skip_no_cassettes
|
|
|
|
pytestmark = [pytest.mark.vcr, skip_no_cassettes]
|
|
|
|
# A storage_state.json with the cookies the token-fetch path needs. SID is the
|
|
# load-bearing cookie for ``auth check``'s ``sid_cookie`` probe; the rest round
|
|
# out a realistic Google auth jar. Values are obviously synthetic -- VCR matches
|
|
# on request shape, never on cookie values, so the recorded homepage GET replays
|
|
# regardless of what is sent.
|
|
_STORAGE_COOKIES = [
|
|
{"name": "SID", "value": "fixture-sid", "domain": ".google.com", "path": "/"},
|
|
{"name": "HSID", "value": "fixture-hsid", "domain": ".google.com", "path": "/"},
|
|
{"name": "SSID", "value": "fixture-ssid", "domain": ".google.com", "path": "/"},
|
|
{"name": "APISID", "value": "fixture-apisid", "domain": ".google.com", "path": "/"},
|
|
{"name": "SAPISID", "value": "fixture-sapisid", "domain": ".google.com", "path": "/"},
|
|
{
|
|
"name": "__Secure-1PSIDTS",
|
|
"value": "fixture-1psidts",
|
|
"domain": ".google.com",
|
|
"path": "/",
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Redirect ``NOTEBOOKLM_HOME`` into ``tmp_path`` with a seeded default profile.
|
|
|
|
Writes a real ``storage_state.json`` under the default profile so the
|
|
token-fetch path loads genuine on-disk cookies (the keepalive needs a
|
|
writable storage file). Clears ``notebooklm.paths`` module caches before and
|
|
after so the sandbox env var actually takes effect, mirroring the
|
|
``isolated_home`` fixtures in ``test_doctor.py`` / ``test_profile.py``.
|
|
|
|
Returns the resolved ``storage_state.json`` path so tests can assert the
|
|
keepalive persisted the rotated jar back to it.
|
|
"""
|
|
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
|
|
monkeypatch.delenv("NOTEBOOKLM_PROFILE", raising=False)
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
paths.set_active_profile(None)
|
|
paths._reset_config_cache()
|
|
|
|
profile_dir = tmp_path / "profiles" / "default"
|
|
profile_dir.mkdir(parents=True)
|
|
try:
|
|
profile_dir.chmod(0o700)
|
|
except (OSError, NotImplementedError):
|
|
pass
|
|
storage_path = profile_dir / "storage_state.json"
|
|
# Seed valid in-band account metadata (``notebooklm.account``) so the
|
|
# keepalive's post-fetch ``_is_valid_account_metadata`` check passes and the
|
|
# "Identifying Google account..." repair path (which would issue a SECOND,
|
|
# un-recorded homepage GET) is skipped -- the cassette records exactly ONE
|
|
# homepage GET, so the keepalive must make exactly one.
|
|
storage_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"cookies": _STORAGE_COOKIES,
|
|
"notebooklm": {"account": {"authuser": 0, "email": "fixture@example.com"}},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "config.json").write_text(
|
|
json.dumps({"default_profile": "default"}), encoding="utf-8"
|
|
)
|
|
|
|
yield storage_path
|
|
|
|
paths.set_active_profile(None)
|
|
paths._reset_config_cache()
|
|
|
|
|
|
class TestAuthCheckCommand:
|
|
"""``notebooklm auth check`` -- local cookie validation (+ optional --test)."""
|
|
|
|
@notebooklm_vcr.use_cassette("cli_auth.yaml")
|
|
def test_check_local_passes(self, runner, isolated_profile: Path) -> None:
|
|
"""Default ``auth check`` validates the seeded jar with NO HTTP.
|
|
|
|
The empty ``cli_auth.yaml`` cassette would trip on any request, so a
|
|
clean exit 0 also proves the no-network contract of the default path.
|
|
"""
|
|
result = runner.invoke(cli, ["auth", "check", "--json"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["checks"]["storage_exists"] is True
|
|
assert data["checks"]["sid_cookie"] is True
|
|
|
|
@notebooklm_vcr.use_cassette("cli_auth.yaml")
|
|
def test_check_missing_storage_fails(
|
|
self, runner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""``auth check`` on a profile with no ``storage_state.json`` reports failure.
|
|
|
|
Still makes no HTTP request (the empty cassette guards that), and exits
|
|
non-zero because the storage-exists probe fails.
|
|
"""
|
|
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
|
|
monkeypatch.delenv("NOTEBOOKLM_PROFILE", raising=False)
|
|
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
|
|
paths.set_active_profile(None)
|
|
paths._reset_config_cache()
|
|
try:
|
|
result = runner.invoke(cli, ["auth", "check", "--json"])
|
|
finally:
|
|
paths.set_active_profile(None)
|
|
paths._reset_config_cache()
|
|
|
|
assert result.exit_code != 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["checks"]["storage_exists"] is False
|
|
|
|
def test_check_test_fetch_round_trip(self, runner, isolated_profile: Path) -> None:
|
|
"""``auth check --test`` replays the homepage token-fetch GET and reports pass.
|
|
|
|
Reuses the refresh cassette's homepage GET (interaction 2). The
|
|
``--test`` path mints CSRF + session from the recorded HTML; a
|
|
``token_fetch == True`` check proves the extractor ran against real
|
|
recorded page chrome, and ``play_count == 1`` proves the round-trip
|
|
actually hit the cassette rather than short-circuiting.
|
|
"""
|
|
with notebooklm_vcr.use_cassette("auth_rotate_cookies_refresh.yaml") as cassette:
|
|
result = runner.invoke(cli, ["auth", "check", "--test", "--json"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
data = json.loads(result.output)
|
|
assert data["checks"]["token_fetch"] is True
|
|
assert cassette.play_count == 1, "expected exactly one recorded homepage GET to replay"
|
|
|
|
|
|
class TestAuthRefreshCommand:
|
|
"""``notebooklm auth refresh`` -- one-shot keepalive token fetch."""
|
|
|
|
def test_refresh_keepalive(self, runner, isolated_profile: Path) -> None:
|
|
"""``auth refresh`` replays the homepage GET and reports the refreshed path.
|
|
|
|
The keepalive loads the seeded jar, performs the homepage GET (replayed
|
|
from the cassette), re-mints CSRF / session, and persists the jar back
|
|
to ``storage_state.json``. Asserts exit 0, the success line naming the
|
|
storage path, and ``play_count == 1`` -- the recorded GET is what makes
|
|
the fetch succeed offline, and the count proves it was actually issued.
|
|
"""
|
|
with notebooklm_vcr.use_cassette("auth_rotate_cookies_refresh.yaml") as cassette:
|
|
result = runner.invoke(cli, ["auth", "refresh"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "refreshed" in result.output
|
|
assert cassette.play_count == 1, "expected exactly one recorded homepage GET to replay"
|
|
# The keepalive's whole point is a writable storage file; it must still
|
|
# exist (and remain valid JSON) after the rotated jar is persisted.
|
|
assert isolated_profile.exists()
|
|
json.loads(isolated_profile.read_text(encoding="utf-8"))
|
|
|
|
def test_refresh_quiet_suppresses_success_line(self, runner, isolated_profile: Path) -> None:
|
|
"""``auth refresh --quiet`` runs the same fetch but prints no output at all.
|
|
|
|
The quiet keepalive still issues (and consumes) the recorded homepage GET
|
|
-- ``play_count == 1`` -- but suppresses the success line entirely, so
|
|
stdout/stderr come back empty.
|
|
"""
|
|
with notebooklm_vcr.use_cassette("auth_rotate_cookies_refresh.yaml") as cassette:
|
|
result = runner.invoke(cli, ["auth", "refresh", "--quiet"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert not result.output.strip(), (
|
|
f"quiet refresh must print nothing, got: {result.output!r}"
|
|
)
|
|
assert cassette.play_count == 1, "expected exactly one recorded homepage GET to replay"
|