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

679 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the cookie-domain blast-radius split.
Pins the two-layer contract:
* ``REQUIRED_COOKIE_DOMAINS`` is the default *extraction* set fed to
rookiepy. This is the canonical enforcement point: sibling-product
cookies (YouTube, etc.) never reach ``storage_state.json`` unless
the user opts in via ``--include-domains`` on
``notebooklm login`` / ``notebooklm auth refresh`` /
``notebooklm auth inspect``.
* The runtime gate consults the full ``REQUIRED OPTIONAL`` union so
opted-in cookies survive every downstream filter
(``convert_rookiepy_cookies_to_storage_state``,
``extract_cookies_with_domains``,
``build_httpx_cookies_from_storage``).
These tests are split out from ``test_auth.py`` because they cross the
auth/cli boundary — the contracts only make sense as a set.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from notebooklm._auth.cookie_policy import ALLOWED_COOKIE_DOMAINS
from notebooklm._auth.cookie_policy import (
build_cookie_domain_allowlist as _neutral_build_cookie_domain_allowlist,
)
from notebooklm._auth.cookie_policy import (
resolve_optional_cookie_domains as _neutral_resolve_optional_cookie_domains,
)
from notebooklm.auth import (
OPTIONAL_COOKIE_DOMAINS,
OPTIONAL_COOKIE_DOMAINS_BY_LABEL,
REQUIRED_COOKIE_DOMAINS,
_is_allowed_cookie_domain,
convert_rookiepy_cookies_to_storage_state,
)
from notebooklm.cli.services.login import (
_build_google_cookie_domains,
_resolve_optional_cookie_domains,
)
from notebooklm.cli.session_cmd import _parse_include_domains
from notebooklm.notebooklm_cli import cli
from tests._fixtures import patch_session_login_dual
class TestNeutralBuilderMatchesCliBuilder:
"""Drift canary: the neutral cookie-domain builder in
``notebooklm._auth.cookie_policy`` (consumed by the Playwright
browser-capture filter) must stay equivalent to the CLI extractor builder
``cli.services.login._build_google_cookie_domains`` (consumed by the
rookiepy / Firefox paths). Both derive from the same shared constants; this
pins that they never silently diverge, so the on-disk ``storage_state.json``
cookie set is identical regardless of which login path wrote it.
"""
@pytest.mark.parametrize(
"include_optional, include_domains",
[
(False, None),
(True, None),
(False, set()),
(False, {"youtube"}),
(False, {"docs"}),
(False, {"myaccount"}),
(False, {"mail"}),
(False, {"youtube", "docs"}),
(False, {"youtube", "docs", "myaccount", "mail"}),
(False, {"all"}),
],
)
def test_builders_produce_identical_domain_sets(self, include_optional, include_domains):
cli_domains = _build_google_cookie_domains(
include_optional=include_optional, include_domains=include_domains
)
neutral_domains = _neutral_build_cookie_domain_allowlist(
include_optional=include_optional, include_domains=include_domains
)
# Order is not significant for the allowlist; compare as sets.
assert set(cli_domains) == set(neutral_domains)
@pytest.mark.parametrize(
"labels",
[set(), {"youtube"}, {"docs", "mail"}, {"all"}],
)
def test_optional_resolvers_match(self, labels):
assert _resolve_optional_cookie_domains(labels) == (
_neutral_resolve_optional_cookie_domains(labels)
)
class TestRequiredVsOptional:
"""REQUIRED is empirically justified; OPTIONAL is opt-in only."""
def test_required_set_includes_runtime_auth_domains(self):
"""Codex caution: host + dotted variants must both stay in REQUIRED."""
for domain in (
".google.com",
"google.com",
".notebooklm.google.com",
"notebooklm.google.com",
".googleusercontent.com",
"accounts.google.com",
".accounts.google.com",
"drive.google.com",
".drive.google.com",
):
assert domain in REQUIRED_COOKIE_DOMAINS, (
f"{domain!r} must remain in REQUIRED_COOKIE_DOMAINS; the "
"runtime gate uses this exact-match set and dropping a "
"variant breaks http.cookiejar normalization round-trips."
)
def test_required_excludes_youtube(self):
"""YouTube is OPTIONAL, not REQUIRED."""
for domain in (".youtube.com", "youtube.com", "accounts.youtube.com"):
assert domain not in REQUIRED_COOKIE_DOMAINS
def test_required_excludes_other_optional_siblings(self):
"""Docs / myaccount / mail are OPTIONAL too."""
for domain in (
"docs.google.com",
"myaccount.google.com",
"mail.google.com",
):
assert domain not in REQUIRED_COOKIE_DOMAINS
def test_optional_label_keys_are_lowercase(self):
"""``--include-domains`` lowercases input; the labels must too."""
for label in OPTIONAL_COOKIE_DOMAINS_BY_LABEL:
assert label == label.lower(), f"label {label!r} is not lowercase"
def test_allowed_is_union(self):
"""``ALLOWED_COOKIE_DOMAINS`` is the back-compat union."""
assert ALLOWED_COOKIE_DOMAINS == REQUIRED_COOKIE_DOMAINS | OPTIONAL_COOKIE_DOMAINS
class TestRuntimeGate:
"""The runtime gate consults the REQUIRED OPTIONAL union.
Blast-radius reduction is enforced at *extraction* time (see
:class:`TestBuildGoogleCookieDomains` and
:class:`TestBlastRadiusExtractor` below): rookiepy only returns cookies
from :data:`REQUIRED_COOKIE_DOMAINS` by default, so YouTube cookies
never reach ``storage_state.json`` unless the user explicitly opts in
via ``--include-domains=youtube``. The runtime gate must stay
permissive over the full union so opted-in cookies survive the
downstream auth/storage filters.
"""
def test_runtime_gate_accepts_youtube_for_opt_in(self):
"""Contract: the runtime gate accepts every OPTIONAL domain.
If it rejected ``.youtube.com`` here, ``--include-domains=youtube``
cookies would be extracted by rookiepy and then immediately
filtered back out by ``convert_rookiepy_cookies_to_storage_state``,
``extract_cookies_with_domains``, and
``build_httpx_cookies_from_storage`` — all of which delegate to
this gate.
"""
assert _is_allowed_cookie_domain(".youtube.com") is True
assert _is_allowed_cookie_domain("youtube.com") is True
assert _is_allowed_cookie_domain("accounts.youtube.com") is True
assert _is_allowed_cookie_domain(".accounts.youtube.com") is True
def test_runtime_gate_accepts_required_set(self):
"""Every REQUIRED domain passes the runtime gate (exact-match tier)."""
for domain in REQUIRED_COOKIE_DOMAINS:
assert _is_allowed_cookie_domain(domain) is True, (
f"{domain!r} must pass the runtime gate (it's in REQUIRED)"
)
def test_runtime_gate_accepts_google_subdomain_optional_siblings(self):
"""Docs / myaccount / Mail pass via the .google.com suffix tier."""
for domain in (
"docs.google.com",
".docs.google.com",
"myaccount.google.com",
".myaccount.google.com",
"mail.google.com",
".mail.google.com",
):
assert _is_allowed_cookie_domain(domain) is True
def test_runtime_gate_still_rejects_lookalikes(self):
"""The permissive gate is still strict against lookalike domains."""
assert _is_allowed_cookie_domain(".not-youtube.com") is False
assert _is_allowed_cookie_domain("notyoutube.com") is False
assert _is_allowed_cookie_domain("evil-google.com") is False
assert _is_allowed_cookie_domain(".google.zz") is False
class TestBuildGoogleCookieDomains:
"""``_build_google_cookie_domains`` is the rookiepy/Firefox feeder."""
def test_default_returns_required_only(self):
"""Default call returns REQUIRED + regional ccTLDs (no OPTIONAL).
This is what changes the rookiepy extraction surface — narrowing
what we ask the browser for at login time.
"""
domains = set(_build_google_cookie_domains())
# Every REQUIRED domain present.
assert REQUIRED_COOKIE_DOMAINS.issubset(domains)
# No OPTIONAL domain present.
assert not (OPTIONAL_COOKIE_DOMAINS & domains)
def test_include_optional_returns_union(self):
"""``include_optional=True`` restores the previous broad set."""
domains = set(_build_google_cookie_domains(include_optional=True))
assert REQUIRED_COOKIE_DOMAINS.issubset(domains)
assert OPTIONAL_COOKIE_DOMAINS.issubset(domains)
def test_include_domains_label_subset(self):
"""``include_domains={'youtube'}`` adds YouTube only — not docs/mail."""
domains = set(_build_google_cookie_domains(include_domains={"youtube"}))
assert REQUIRED_COOKIE_DOMAINS.issubset(domains)
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["youtube"].issubset(domains)
# Docs / mail / myaccount are NOT pulled in.
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["docs"].isdisjoint(domains)
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["mail"].isdisjoint(domains)
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["myaccount"].isdisjoint(domains)
def test_include_domains_multi_label(self):
"""``include_domains={'youtube', 'docs'}`` is the union of both."""
domains = set(_build_google_cookie_domains(include_domains={"youtube", "docs"}))
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["youtube"].issubset(domains)
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["docs"].issubset(domains)
# mail / myaccount NOT pulled in.
assert OPTIONAL_COOKIE_DOMAINS_BY_LABEL["mail"].isdisjoint(domains)
def test_include_domains_all_shortcut(self):
"""``include_domains={'all'}`` is equivalent to every label."""
domains = set(_build_google_cookie_domains(include_domains={"all"}))
for optional_set in OPTIONAL_COOKIE_DOMAINS_BY_LABEL.values():
assert optional_set.issubset(domains)
class TestParseIncludeDomains:
"""``_parse_include_domains`` accepts both flag forms and rejects unknowns."""
def test_empty_returns_empty(self):
assert _parse_include_domains(()) == set()
def test_single_label(self):
assert _parse_include_domains(("youtube",)) == {"youtube"}
def test_multiple_flag_invocations(self):
"""``--include-domains=youtube --include-domains=docs``."""
assert _parse_include_domains(("youtube", "docs")) == {"youtube", "docs"}
def test_comma_separated_single_invocation(self):
"""``--include-domains=youtube,docs`` (the codex-flagged form)."""
assert _parse_include_domains(("youtube,docs",)) == {"youtube", "docs"}
def test_mixed_comma_and_repeated(self):
"""Mix of both forms in the same call."""
labels = _parse_include_domains(("youtube,docs", "mail"))
assert labels == {"youtube", "docs", "mail"}
def test_whitespace_around_commas_tolerated(self):
assert _parse_include_domains(("youtube , docs",)) == {"youtube", "docs"}
def test_lowercased(self):
assert _parse_include_domains(("YouTube,DOCS",)) == {"youtube", "docs"}
def test_empty_fragments_dropped(self):
assert _parse_include_domains(("youtube,,docs,",)) == {"youtube", "docs"}
def test_unknown_label_raises(self):
"""Click surfaces this as a non-zero exit + stderr message."""
import click
with pytest.raises(click.BadParameter) as exc_info:
_parse_include_domains(("zoom",))
assert "zoom" in str(exc_info.value)
# Help text lists the supported labels.
assert "youtube" in str(exc_info.value)
def test_all_label_accepted(self):
assert _parse_include_domains(("all",)) == {"all"}
class TestResolveOptionalCookieDomains:
"""``_resolve_optional_cookie_domains`` flattens labels to a domain set."""
def test_empty_labels(self):
assert _resolve_optional_cookie_domains(set()) == frozenset()
def test_single_label_resolves(self):
result = _resolve_optional_cookie_domains({"youtube"})
assert result == OPTIONAL_COOKIE_DOMAINS_BY_LABEL["youtube"]
def test_all_label_returns_full_optional_set(self):
assert _resolve_optional_cookie_domains({"all"}) == OPTIONAL_COOKIE_DOMAINS
def test_all_takes_precedence_when_combined(self):
"""``--include-domains=all,youtube`` resolves to the full OPTIONAL set."""
assert _resolve_optional_cookie_domains({"all", "youtube"}) == OPTIONAL_COOKIE_DOMAINS
class TestBlastRadiusExtractor:
"""Blast-radius reduction is enforced at extraction time, not runtime.
Contract: rookiepy is asked only for ``REQUIRED_COOKIE_DOMAINS`` by
default. Sibling-product cookies (YouTube) therefore never enter
``storage_state.json`` unless the user explicitly opts in. The
downstream runtime gate is permissive over the full union so
opted-in cookies still flow through.
These tests pin the contract at the *only* choke point that gates
blast radius: the domain list fed to the browser-cookie extractor.
"""
def _raw_cookie(self, domain: str, name: str) -> dict:
return {
"domain": domain,
"name": name,
"value": "v",
"path": "/",
"secure": True,
"expires": None,
"http_only": False,
}
def test_default_extraction_list_excludes_youtube(self):
"""Default login asks rookiepy for REQUIRED only — no YouTube domains.
The actual blast-radius control: rookiepy never returns YouTube
cookies under the default flag set, so they never reach
``storage_state.json``. The downstream runtime gate stays
permissive so opted-in cookies survive the filters.
"""
youtube_variants = frozenset({".youtube.com", "youtube.com"})
domains_default = frozenset(_build_google_cookie_domains())
assert domains_default.isdisjoint(youtube_variants)
def test_opt_in_then_default_resets_extraction_set(self):
"""After ``--include-domains=youtube`` then a default re-login, no YouTube.
Simulates the codex-flagged "user opted in once, then forgot
and re-ran default" case. The extractor must actively exclude on
re-run, not just default to a smaller request set.
"""
youtube_variants = frozenset({".youtube.com", "youtube.com"})
# First run with --include-domains=youtube: YouTube cookies extracted.
domains_optin = frozenset(_build_google_cookie_domains(include_domains={"youtube"}))
# Set-intersection form sidesteps CodeQL's substring-sanitization
# heuristic.
assert youtube_variants & domains_optin
# Second run with default: YouTube is NOT in the extraction list.
domains_default = frozenset(_build_google_cookie_domains())
assert domains_default.isdisjoint(youtube_variants)
def test_youtube_cookies_survive_when_opted_in(self, tmp_path: Path):
"""End-to-end: ``--include-domains=youtube`` persists YouTube cookies.
The Gemini-flagged regression: under the original tightened
runtime gate, ``--include-domains=youtube`` was a silent no-op
because every downstream filter
(:func:`convert_rookiepy_cookies_to_storage_state`,
:func:`extract_cookies_with_domains`,
:func:`build_httpx_cookies_from_storage`) dropped the cookies
again on the way through. This test exercises the full pipeline
and asserts opted-in cookies survive end-to-end.
"""
from notebooklm.auth import (
_is_allowed_auth_domain,
build_httpx_cookies_from_storage,
extract_cookies_with_domains,
)
# Simulate the rookiepy output for a user who passed
# ``--include-domains=youtube``: REQUIRED auth cookies plus a YouTube
# opt-in cookie.
raw = [
self._raw_cookie(".google.com", "SID"),
self._raw_cookie(".google.com", "__Secure-1PSIDTS"),
self._raw_cookie(".youtube.com", "LOGIN_INFO"),
]
# Step 1: rookiepy → storage_state conversion must keep the YouTube
# cookie (the runtime gate is permissive over the union).
storage_state = convert_rookiepy_cookies_to_storage_state(raw)
kept_domains = frozenset(c["domain"] for c in storage_state["cookies"])
assert {".youtube.com"} <= kept_domains, (
"convert_rookiepy_cookies_to_storage_state must keep YouTube "
"cookies once they have been extracted; the runtime gate is "
"permissive over the union."
)
assert any(c["name"] == "LOGIN_INFO" for c in storage_state["cookies"])
# Step 2: storage_state → DomainCookieMap (used by AuthTokens).
cookie_map = extract_cookies_with_domains(storage_state)
assert ("LOGIN_INFO", ".youtube.com", "/") in cookie_map, (
"extract_cookies_with_domains must keep YouTube cookies for the opt-in path."
)
# Step 3: storage_state → httpx jar (used by downloads + refresh).
storage_file = tmp_path / "storage_state.json"
storage_file.write_text(json.dumps(storage_state))
jar = build_httpx_cookies_from_storage(storage_file)
assert jar.get("LOGIN_INFO", domain=".youtube.com") == "v", (
"build_httpx_cookies_from_storage must keep YouTube cookies for the opt-in path."
)
# Step 4: the runtime gate itself accepts every YouTube variant.
for domain in (".youtube.com", "youtube.com", "accounts.youtube.com"):
assert _is_allowed_auth_domain(domain) is True, (
f"_is_allowed_auth_domain({domain!r}) must accept the domain so "
"opted-in cookies survive every downstream filter."
)
class TestTokenVerificationStillWorksAfterMinimumSet:
"""Token-verification regression test (load-bearing for the cookie-domain split).
Asserts that a storage_state.json built from the minimum REQUIRED set
still satisfies the auth-jar construction path — i.e. the same flow
``fetch_tokens_with_domains`` uses. We stop before any network call
because the unit-test suite must run offline; instead we assert that
the storage state passes the upstream validators that ``fetch_tokens``
relies on.
"""
def test_minimum_required_storage_state_validates(self, tmp_path: Path):
"""A storage_state built from REQUIRED-only cookies passes ``extract_cookies_from_storage``.
``extract_cookies_from_storage`` is the validator
``fetch_tokens_with_domains`` calls before making any network
request. If REQUIRED is too narrow, this validation raises
``ValueError`` and token-fetch never even starts.
"""
from notebooklm.auth import extract_cookies_from_storage
# Minimum cookies that a real login must produce. SID is the
# canonical guard (MINIMUM_REQUIRED_COOKIES). The companion auth
# cookies (HSID/SSID/APISID/SAPISID) live on .google.com, fully
# within REQUIRED.
storage_state = {
"cookies": [
{"name": "SID", "value": "v_sid", "domain": ".google.com"},
{"name": "HSID", "value": "v_hsid", "domain": ".google.com"},
{"name": "SSID", "value": "v_ssid", "domain": ".google.com"},
{"name": "APISID", "value": "v_apisid", "domain": ".google.com"},
{"name": "SAPISID", "value": "v_sapisid", "domain": ".google.com"},
# Notebooklm session cookie on the API host.
{
"name": "__Secure-1PSIDTS",
"value": "v_1psidts",
"domain": ".google.com",
},
],
"origins": [],
}
cookies = extract_cookies_from_storage(storage_state)
assert cookies["SID"] == "v_sid"
assert cookies["HSID"] == "v_hsid"
def test_minimum_required_set_round_trips_through_load_httpx_cookies(self, tmp_path: Path):
"""The httpx jar (used by downloads + refresh) is non-empty for REQUIRED-only state."""
from notebooklm._auth.cookies import load_httpx_cookies
storage_state = {
"cookies": [
{"name": "SID", "value": "v_sid", "domain": ".google.com"},
{"name": "HSID", "value": "v_hsid", "domain": ".google.com"},
{"name": "SSID", "value": "v_ssid", "domain": ".google.com"},
{"name": "APISID", "value": "v_apisid", "domain": ".google.com"},
{"name": "SAPISID", "value": "v_sapisid", "domain": ".google.com"},
{
"name": "__Secure-1PSIDTS",
"value": "v_1psidts",
"domain": ".google.com",
},
],
"origins": [],
}
storage_file = tmp_path / "storage.json"
storage_file.write_text(json.dumps(storage_state))
jar = load_httpx_cookies(path=storage_file)
assert jar.get("SID", domain=".google.com") == "v_sid"
class TestLoginCliFlag:
"""``notebooklm login --include-domains`` plumbs through to the extractor."""
def test_login_help_advertises_include_domains(self):
"""``login --help`` mentions the new flag and the supported labels."""
runner = CliRunner()
result = runner.invoke(cli, ["login", "--help"])
assert result.exit_code == 0
assert "--include-domains" in result.output
assert "youtube" in result.output
def test_login_rejects_unknown_include_domain_label(self, monkeypatch):
"""Unknown labels are surfaced as a click error (non-zero exit + stderr)."""
# Block the env-var conflict guard from firing.
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
runner = CliRunner()
result = runner.invoke(
cli,
[
"login",
"--browser-cookies",
"chrome",
"--include-domains",
"zoom",
],
)
assert result.exit_code != 0
# Click renders BadParameter to stderr-style output captured in result.output.
assert "zoom" in result.output or "zoom" in (result.stderr or "")
def test_login_include_domains_forwards_to_extractor(self, monkeypatch):
"""``--include-domains=youtube`` is parsed and threaded to login helper."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
runner = CliRunner()
with patch_session_login_dual("_login_browser_cookies_single") as login_single:
result = runner.invoke(
cli,
[
"login",
"--browser-cookies",
"chrome",
"--include-domains",
"youtube,docs",
],
)
assert result.exit_code == 0, result.output
login_single.assert_called_once()
kwargs = login_single.call_args.kwargs
assert kwargs["include_domains"] == {"youtube", "docs"}
def test_login_default_no_include_domains_emits_migration_note(self, monkeypatch):
"""Default browser-cookies login prints the migration note."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
runner = CliRunner()
with patch_session_login_dual("_login_browser_cookies_single"):
result = runner.invoke(
cli,
["login", "--browser-cookies", "chrome"],
)
assert result.exit_code == 0, result.output
assert "sibling-product cookies not included" in result.output
def test_login_with_include_domains_suppresses_migration_note(self, monkeypatch):
"""The migration note is suppressed once the user opts in."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
runner = CliRunner()
with patch_session_login_dual("_login_browser_cookies_single"):
result = runner.invoke(
cli,
[
"login",
"--browser-cookies",
"chrome",
"--include-domains",
"all",
],
)
assert result.exit_code == 0, result.output
assert "sibling-product cookies not included" not in result.output
def test_login_include_domains_on_playwright_path_no_longer_warns(self, monkeypatch, tmp_path):
"""``--include-domains`` now applies on the Playwright path (P1-17).
Prior to P1-17 the Playwright login flow ignored ``--include-domains``
and emitted a "no effect" warning. Now the flag drives the
write-time cookie-domain allowlist filter so sibling-product cookies
only get persisted when the user explicitly opts in. Confirm the
legacy warning is gone — the test guarding the old behavior is
repurposed as a regression guard so we don't reintroduce it.
"""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
runner = CliRunner()
# Stub Playwright import to break out before any real browser launch;
# we only care about the pre-launch warning emission path.
with patch.dict("sys.modules", {"playwright": None, "playwright.sync_api": None}):
result = runner.invoke(cli, ["login", "--include-domains", "youtube"])
# CodeRabbit feedback: pin the failure path so the test cannot pass
# spuriously if the warning never fired (e.g. if Playwright became
# available via some other import shim). The Playwright-not-installed
# branch in ``_run_playwright_login`` exits 1 with a clear message.
assert result.exit_code == 1
assert "Playwright not installed" in result.output
assert "--include-domains has no effect without --browser-cookies" not in result.output
class TestAuthRefreshCliFlag:
"""``notebooklm auth refresh --include-domains`` follows the same plumbing."""
def test_refresh_include_domains_requires_browser_cookies(self, monkeypatch, tmp_path):
"""``--include-domains`` without ``--browser-cookies`` is rejected."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
# A storage file has to exist so refresh doesn't bail on FileNotFound
# before reaching the validation.
(tmp_path / "storage_state.json").write_text("{}")
runner = CliRunner()
result = runner.invoke(cli, ["auth", "refresh", "--include-domains", "youtube"])
assert result.exit_code != 0
assert "--browser-cookies" in result.output
def test_refresh_include_domains_forwards_when_browser_cookies_set(self, monkeypatch, tmp_path):
"""``--include-domains`` is forwarded to the browser-refresh helper."""
monkeypatch.delenv("NOTEBOOKLM_AUTH_JSON", raising=False)
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
runner = CliRunner()
with patch_session_login_dual("_refresh_from_browser_cookies") as helper:
result = runner.invoke(
cli,
[
"auth",
"refresh",
"--browser-cookies",
"chrome",
"--include-domains",
"youtube",
],
)
assert result.exit_code == 0, result.output
helper.assert_called_once()
assert helper.call_args.kwargs["include_domains"] == {"youtube"}
class TestAuthInspectCliFlag:
"""``notebooklm auth inspect --include-domains`` thread-through."""
def test_inspect_help_advertises_include_domains(self):
runner = CliRunner()
result = runner.invoke(cli, ["auth", "inspect", "--help"])
assert result.exit_code == 0
assert "--include-domains" in result.output
def test_inspect_include_domains_forwarded_to_enumerate(self):
"""``--include-domains=youtube`` reaches ``_enumerate_browser_accounts``."""
runner = CliRunner()
with patch_session_login_dual("_enumerate_browser_accounts") as enum:
enum.return_value = ([], [])
result = runner.invoke(
cli,
[
"auth",
"inspect",
"--browser",
"chrome",
"--include-domains",
"youtube",
"--json",
],
)
assert result.exit_code == 0, result.output
enum.assert_called_once()
assert enum.call_args.kwargs["include_domains"] == {"youtube"}