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
1388 lines
57 KiB
Python
1388 lines
57 KiB
Python
"""Tests for auth cookie-domain policy and allowlist (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
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from notebooklm._auth.cookies import load_httpx_cookies
|
|
from notebooklm._auth.storage import save_cookies_to_storage, snapshot_cookie_jar
|
|
from notebooklm.auth import (
|
|
build_httpx_cookies_from_storage,
|
|
convert_rookiepy_cookies_to_storage_state,
|
|
extract_cookies_from_storage,
|
|
extract_cookies_with_domains,
|
|
)
|
|
|
|
|
|
class TestIsAllowedCookieDomain:
|
|
"""Test cookie domain validation security."""
|
|
|
|
def test_accepts_exact_matches_from_allowlist(self):
|
|
"""Test accepts domains in ALLOWED_COOKIE_DOMAINS."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
assert _is_allowed_cookie_domain(".google.com") is True
|
|
assert _is_allowed_cookie_domain("notebooklm.google.com") is True
|
|
assert _is_allowed_cookie_domain("notebooklm.cloud.google.com") is True
|
|
assert _is_allowed_cookie_domain(".notebooklm.cloud.google.com") is True
|
|
assert _is_allowed_cookie_domain(".googleusercontent.com") is True
|
|
assert _is_allowed_cookie_domain(".accounts.google.com") is True
|
|
|
|
def test_accepts_valid_google_subdomains(self):
|
|
"""Test accepts legitimate Google subdomains."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
assert _is_allowed_cookie_domain("lh3.google.com") is True
|
|
assert _is_allowed_cookie_domain("accounts.google.com") is True
|
|
assert _is_allowed_cookie_domain("www.google.com") is True
|
|
|
|
def test_accepts_googleusercontent_subdomains(self):
|
|
"""Test accepts googleusercontent.com subdomains."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
assert _is_allowed_cookie_domain("lh3.googleusercontent.com") is True
|
|
assert _is_allowed_cookie_domain("drum.usercontent.google.com") is True
|
|
|
|
def test_rejects_malicious_lookalike_domains(self):
|
|
"""Test rejects domains like 'evil-google.com' that end with google.com."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
# These domains end with ".google.com" but are NOT subdomains
|
|
assert _is_allowed_cookie_domain("evil-google.com") is False
|
|
assert _is_allowed_cookie_domain("malicious-google.com") is False
|
|
assert _is_allowed_cookie_domain("fakegoogle.com") is False
|
|
|
|
def test_rejects_fake_googleusercontent_domains(self):
|
|
"""Test rejects fake googleusercontent domains."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
assert _is_allowed_cookie_domain("evil-googleusercontent.com") is False
|
|
assert _is_allowed_cookie_domain("fakegoogleusercontent.com") is False
|
|
|
|
def test_rejects_unrelated_domains(self):
|
|
"""Test rejects completely unrelated domains."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
assert _is_allowed_cookie_domain("example.com") is False
|
|
assert _is_allowed_cookie_domain("evil.com") is False
|
|
assert _is_allowed_cookie_domain("google.evil.com") is False
|
|
|
|
|
|
# =============================================================================
|
|
# CONSTANT TESTS
|
|
# =============================================================================
|
|
|
|
|
|
class TestIsGoogleDomain:
|
|
"""Test the unified _is_google_domain function (whitelist approach)."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"domain,expected",
|
|
[
|
|
# Base Google domain
|
|
(".google.com", True),
|
|
# .google.com.XX pattern (country-code second-level domains)
|
|
(".google.com.sg", True), # Singapore
|
|
(".google.com.au", True), # Australia
|
|
(".google.com.br", True), # Brazil
|
|
(".google.com.hk", True), # Hong Kong
|
|
(".google.com.tw", True), # Taiwan
|
|
(".google.com.mx", True), # Mexico
|
|
(".google.com.ar", True), # Argentina
|
|
(".google.com.tr", True), # Turkey
|
|
(".google.com.ua", True), # Ukraine
|
|
# .google.co.XX pattern (countries using .co)
|
|
(".google.co.uk", True), # United Kingdom
|
|
(".google.co.jp", True), # Japan
|
|
(".google.co.in", True), # India
|
|
(".google.co.kr", True), # South Korea
|
|
(".google.co.za", True), # South Africa
|
|
(".google.co.nz", True), # New Zealand
|
|
(".google.co.id", True), # Indonesia
|
|
(".google.co.th", True), # Thailand
|
|
# .google.XX pattern (single ccTLD)
|
|
(".google.cn", True), # China
|
|
(".google.de", True), # Germany
|
|
(".google.fr", True), # France
|
|
(".google.it", True), # Italy
|
|
(".google.es", True), # Spain
|
|
(".google.nl", True), # Netherlands
|
|
(".google.pl", True), # Poland
|
|
(".google.ru", True), # Russia
|
|
(".google.ca", True), # Canada
|
|
(".google.cat", True), # Catalonia (3-letter special case)
|
|
# Invalid domains that should be rejected
|
|
(".google.zz", False), # Invalid ccTLD
|
|
(".google.xyz", False), # Not in whitelist
|
|
(".google.com.fake", False), # Not in whitelist
|
|
(".notebooklm.google.com", False), # Accepted by auth allowlist, not here
|
|
(".mail.google.com", False),
|
|
(".drive.google.com", False),
|
|
(".evilnotebooklm.google.com", False),
|
|
(".notebooklm.google.com.evil", False),
|
|
(".notgoogle.com", False),
|
|
(".evil-google.com", False),
|
|
("google.com", False), # Missing leading dot
|
|
("google.com.sg", False), # Missing leading dot
|
|
(".youtube.com", False),
|
|
(".google.", False), # Incomplete
|
|
("", False), # Empty
|
|
],
|
|
)
|
|
def test_is_google_domain(self, domain, expected):
|
|
"""Test _is_google_domain with various domain patterns."""
|
|
from notebooklm.auth import _is_google_domain
|
|
|
|
assert _is_google_domain(domain) is expected
|
|
|
|
@pytest.mark.parametrize(
|
|
"domain",
|
|
[
|
|
# Case sensitivity - cookie domains per RFC should be lowercase
|
|
".GOOGLE.COM",
|
|
".Google.Com",
|
|
".google.COM.SG",
|
|
".GOOGLE.CO.UK",
|
|
".GOOGLE.DE",
|
|
],
|
|
)
|
|
def test_rejects_uppercase_domains(self, domain):
|
|
"""Test that uppercase domains are rejected (case-sensitive matching).
|
|
|
|
Per RFC 6265, cookie domains SHOULD be lowercase. Playwright and browsers
|
|
normalize domains to lowercase, so we don't need case-insensitive matching.
|
|
"""
|
|
from notebooklm.auth import _is_google_domain
|
|
|
|
assert _is_google_domain(domain) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"domain",
|
|
[
|
|
" .google.com", # Leading space
|
|
".google.com ", # Trailing space
|
|
"\t.google.com", # Tab
|
|
".google.com\n", # Newline
|
|
],
|
|
)
|
|
def test_rejects_domains_with_whitespace(self, domain):
|
|
"""Test that domains with whitespace are rejected."""
|
|
from notebooklm.auth import _is_google_domain
|
|
|
|
assert _is_google_domain(domain) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"domain",
|
|
[
|
|
".google..com", # Double dot
|
|
"..google.com", # Leading double dot
|
|
".google.com.", # Trailing dot
|
|
],
|
|
)
|
|
def test_rejects_malformed_domains(self, domain):
|
|
"""Test that malformed domains with extra dots are rejected."""
|
|
from notebooklm.auth import _is_google_domain
|
|
|
|
assert _is_google_domain(domain) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"domain",
|
|
[
|
|
# Subdomains without leading dot are still rejected
|
|
"accounts.google.com",
|
|
"lh3.google.com",
|
|
# Subdomains of regional domains are still rejected (not whitelisted)
|
|
"accounts.google.de",
|
|
"lh3.google.co.uk",
|
|
".accounts.google.de", # Leading dot but regional subdomain
|
|
],
|
|
)
|
|
def test_rejects_subdomains(self, domain):
|
|
"""Test that non-canonical subdomains are rejected by _is_google_domain.
|
|
|
|
_is_google_domain only accepts .google.com and regional root domains
|
|
(.google.com.sg, etc). Auth extraction uses ALLOWED_COOKIE_DOMAINS for
|
|
auth-specific subdomains.
|
|
"""
|
|
from notebooklm.auth import _is_google_domain
|
|
|
|
assert _is_google_domain(domain) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"domain",
|
|
[
|
|
".google.com.sg.fake", # Extra suffix
|
|
".fake.google.com.sg", # Prefix on regional
|
|
".google.com.sgx", # Extended TLD
|
|
".google.co.ukx", # Extended co.XX
|
|
".google.dex", # Extended ccTLD
|
|
],
|
|
)
|
|
def test_rejects_suffix_exploits(self, domain):
|
|
"""Test that attempts to exploit suffix matching are rejected."""
|
|
from notebooklm.auth import _is_google_domain
|
|
|
|
assert _is_google_domain(domain) is False
|
|
|
|
|
|
class TestIsAllowedAuthDomain:
|
|
"""Test auth cookie domain validation including regional Google domains."""
|
|
|
|
def test_accepts_primary_google_domains(self):
|
|
"""Test accepts domains in ALLOWED_COOKIE_DOMAINS."""
|
|
from notebooklm.auth import _is_allowed_auth_domain
|
|
|
|
assert _is_allowed_auth_domain(".google.com") is True
|
|
assert _is_allowed_auth_domain("notebooklm.google.com") is True
|
|
assert _is_allowed_auth_domain(".notebooklm.google.com") is True # Issue #329
|
|
assert _is_allowed_auth_domain(".googleusercontent.com") is True
|
|
|
|
def test_accepts_all_regional_patterns(self):
|
|
"""Test accepts all three regional Google domain patterns (Issue #20)."""
|
|
from notebooklm.auth import _is_allowed_auth_domain
|
|
|
|
# .google.com.XX pattern
|
|
assert _is_allowed_auth_domain(".google.com.sg") is True # Singapore
|
|
assert _is_allowed_auth_domain(".google.com.au") is True # Australia
|
|
|
|
# .google.co.XX pattern
|
|
assert _is_allowed_auth_domain(".google.co.uk") is True # UK
|
|
assert _is_allowed_auth_domain(".google.co.jp") is True # Japan
|
|
|
|
# .google.XX pattern
|
|
assert _is_allowed_auth_domain(".google.de") is True # Germany
|
|
assert _is_allowed_auth_domain(".google.fr") is True # France
|
|
|
|
def test_accepts_youtube_for_opt_in_post_t5g(self):
|
|
"""YouTube cookies pass the runtime gate so ``--include-domains=youtube``
|
|
works end-to-end.
|
|
|
|
The cookie-domain split enforces blast-radius reduction at
|
|
*extraction* time (rookiepy is asked for
|
|
:data:`REQUIRED_COOKIE_DOMAINS` only). The runtime gate stays
|
|
permissive over the full union
|
|
(:data:`ALLOWED_COOKIE_DOMAINS`) so opted-in YouTube cookies
|
|
survive ``convert_rookiepy_cookies_to_storage_state``,
|
|
``extract_cookies_with_domains``, and
|
|
``build_httpx_cookies_from_storage`` — all of which delegate to
|
|
this gate.
|
|
"""
|
|
from notebooklm.auth import _is_allowed_auth_domain
|
|
|
|
assert _is_allowed_auth_domain(".youtube.com") is True
|
|
assert _is_allowed_auth_domain("youtube.com") is True
|
|
assert _is_allowed_auth_domain("accounts.youtube.com") is True
|
|
assert _is_allowed_auth_domain(".accounts.youtube.com") is True
|
|
|
|
def test_accepts_sibling_google_subdomains(self):
|
|
"""Sibling Google subdomains pass via the ``.google.com`` suffix.
|
|
|
|
Drive / Docs / myaccount / Mail are subdomains of ``.google.com``,
|
|
so the runtime gate accepts them via the suffix branch (tier 3 of
|
|
:func:`_is_allowed_cookie_domain`) even though only ``drive.*`` is
|
|
in :data:`REQUIRED_COOKIE_DOMAINS`.
|
|
"""
|
|
from notebooklm.auth import _is_allowed_auth_domain
|
|
|
|
# Drive (in REQUIRED) — exact-match tier
|
|
assert _is_allowed_auth_domain("drive.google.com") is True
|
|
assert _is_allowed_auth_domain(".drive.google.com") is True
|
|
# Docs / myaccount / mail — accepted via .google.com suffix tier
|
|
# (storage_state.json may still contain these from legacy logins).
|
|
assert _is_allowed_auth_domain("docs.google.com") is True
|
|
assert _is_allowed_auth_domain(".docs.google.com") is True
|
|
assert _is_allowed_auth_domain("myaccount.google.com") is True
|
|
assert _is_allowed_auth_domain(".myaccount.google.com") is True
|
|
assert _is_allowed_auth_domain("mail.google.com") is True
|
|
|
|
def test_rejects_unrelated_domains(self):
|
|
"""Test rejects non-Google domains."""
|
|
from notebooklm.auth import _is_allowed_auth_domain
|
|
|
|
assert _is_allowed_auth_domain("evil.com") is False
|
|
assert _is_allowed_auth_domain(".evil-google.com") is False
|
|
assert _is_allowed_auth_domain(".not-youtube.com") is False
|
|
assert _is_allowed_auth_domain("notyoutube.com") is False
|
|
|
|
def test_rejects_malicious_google_lookalikes(self):
|
|
"""Test rejects domains that look like Google but aren't."""
|
|
from notebooklm.auth import _is_allowed_auth_domain
|
|
|
|
assert _is_allowed_auth_domain("google.com.evil.sg") is False
|
|
# Note: post-#360 unification, .mail.google.com is accepted (it's a
|
|
# legitimate Google-owned subdomain). Only foreign suffixes are rejected.
|
|
assert _is_allowed_auth_domain(".google.com.evil") is False
|
|
assert _is_allowed_auth_domain(".evilnotebooklm.google.com.evil") is False
|
|
assert _is_allowed_auth_domain(".not-google.com.sg") is False
|
|
assert _is_allowed_auth_domain(".google.zz") is False # Invalid ccTLD
|
|
|
|
def test_requires_leading_dot_for_regional(self):
|
|
"""Test regional domains require leading dot.
|
|
|
|
Regional ccTLDs like ``google.com.sg`` (no leading dot) are not in
|
|
ALLOWED_COOKIE_DOMAINS and are not accepted by ``_is_google_domain``
|
|
(which requires the leading dot for regional patterns) or by the
|
|
suffix paths (which require the leading-dot suffix).
|
|
"""
|
|
from notebooklm.auth import _is_allowed_auth_domain
|
|
|
|
assert _is_allowed_auth_domain("google.com.sg") is False
|
|
assert _is_allowed_auth_domain("google.co.uk") is False
|
|
assert _is_allowed_auth_domain("google.de") is False
|
|
|
|
|
|
class TestAuthDomainPriority:
|
|
"""Test `_auth_domain_priority` tier mapping for duplicate-cookie resolution."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"domain,expected",
|
|
[
|
|
(".google.com", 4),
|
|
(".notebooklm.google.com", 3),
|
|
(".notebooklm.cloud.google.com", 3),
|
|
("notebooklm.google.com", 2),
|
|
("notebooklm.cloud.google.com", 2),
|
|
(".google.de", 1),
|
|
(".google.com.sg", 1),
|
|
(".google.co.uk", 1),
|
|
(".googleusercontent.com", 0),
|
|
("evil.com", 0),
|
|
("", 0),
|
|
],
|
|
)
|
|
def test_priority_tiers(self, domain, expected):
|
|
from notebooklm.auth import _auth_domain_priority
|
|
|
|
assert _auth_domain_priority(domain) == expected
|
|
|
|
def test_priority_strict_ordering(self):
|
|
"""Higher tiers strictly outrank lower tiers — no ties between named tiers."""
|
|
from notebooklm.auth import _auth_domain_priority
|
|
|
|
priorities = [
|
|
_auth_domain_priority(".google.com"),
|
|
_auth_domain_priority(".notebooklm.google.com"),
|
|
_auth_domain_priority("notebooklm.google.com"),
|
|
_auth_domain_priority(".google.de"),
|
|
_auth_domain_priority(".googleusercontent.com"),
|
|
]
|
|
assert priorities == sorted(priorities, reverse=True)
|
|
assert len(set(priorities)) == len(priorities)
|
|
|
|
|
|
class TestIsAllowedCookieDomainRegional:
|
|
"""Test _is_allowed_cookie_domain with regional Google domains."""
|
|
|
|
def test_accepts_regional_google_domains_for_downloads(self):
|
|
"""Test that download cookie validation accepts regional domains."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
# .google.com.XX pattern
|
|
assert _is_allowed_cookie_domain(".google.com.sg") is True
|
|
assert _is_allowed_cookie_domain(".google.com.au") is True
|
|
|
|
# .google.co.XX pattern
|
|
assert _is_allowed_cookie_domain(".google.co.uk") is True
|
|
assert _is_allowed_cookie_domain(".google.co.jp") is True
|
|
|
|
# .google.XX pattern
|
|
assert _is_allowed_cookie_domain(".google.de") is True
|
|
assert _is_allowed_cookie_domain(".google.fr") is True
|
|
|
|
def test_still_accepts_subdomains(self):
|
|
"""Test that subdomain suffix matching still works."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
assert _is_allowed_cookie_domain("lh3.google.com") is True
|
|
assert _is_allowed_cookie_domain("accounts.google.com") is True
|
|
assert _is_allowed_cookie_domain("lh3.googleusercontent.com") is True
|
|
|
|
def test_youtube_accepted_for_opt_in_post_t5g(self):
|
|
"""YouTube remains in the runtime allowlist.
|
|
|
|
Blast-radius reduction is enforced at extraction time, not by the
|
|
runtime gate. See :class:`TestIsAllowedAuthDomain` for the
|
|
rationale and :class:`TestSiblingGoogleProductExtraction` for the
|
|
extraction-time contracts.
|
|
"""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
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_accepts_sibling_google_subdomains(self):
|
|
"""Sibling Google subdomains still pass via the ``.google.com`` suffix tier."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
assert _is_allowed_cookie_domain("drive.google.com") is True
|
|
assert _is_allowed_cookie_domain("docs.google.com") is True
|
|
assert _is_allowed_cookie_domain("myaccount.google.com") is True
|
|
assert _is_allowed_cookie_domain("mail.google.com") is True
|
|
|
|
def test_rejects_invalid_domains(self):
|
|
"""Test rejects invalid domains."""
|
|
from notebooklm.auth import _is_allowed_cookie_domain
|
|
|
|
assert _is_allowed_cookie_domain(".google.zz") is False
|
|
assert _is_allowed_cookie_domain("evil-google.com") is False
|
|
assert _is_allowed_cookie_domain(".not-youtube.com") is False
|
|
assert _is_allowed_cookie_domain("notyoutube.com") is False
|
|
|
|
|
|
class TestExtractCookiesRegionalDomains:
|
|
"""Test cookie extraction from regional Google domains (Issue #20, #27)."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"domain,sid_value,description",
|
|
[
|
|
(".google.com.sg", "sid_from_singapore", "Issue #20 - Singapore"),
|
|
(".google.cn", "sid_from_china", "Issue #27 - China"),
|
|
(".google.co.uk", "sid_from_uk", "UK regional domain"),
|
|
(".google.de", "sid_from_de", "Germany regional domain"),
|
|
],
|
|
)
|
|
def test_extracts_sid_from_regional_domain(self, domain, sid_value, description):
|
|
"""Test extracts SID cookie from regional Google domains."""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": sid_value, "domain": domain},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": domain},
|
|
{"name": "OSID", "value": "osid_value", "domain": "notebooklm.google.com"},
|
|
]
|
|
}
|
|
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
|
|
assert cookies["SID"] == sid_value
|
|
assert cookies["OSID"] == "osid_value"
|
|
|
|
def test_extracts_sid_from_all_regional_patterns(self):
|
|
"""Test extracts SID from all three regional domain patterns."""
|
|
# Test .google.com.XX
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_sg", "domain": ".google.com.sg"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com.sg"},
|
|
]
|
|
}
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
assert cookies["SID"] == "sid_sg"
|
|
|
|
# Test .google.co.XX
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_uk", "domain": ".google.co.uk"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.co.uk"},
|
|
]
|
|
}
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
assert cookies["SID"] == "sid_uk"
|
|
|
|
# Test .google.XX
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_de", "domain": ".google.de"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.de"},
|
|
]
|
|
}
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
assert cookies["SID"] == "sid_de"
|
|
|
|
def test_extracts_multiple_regional_cookies(self):
|
|
"""Test extracts cookies from multiple regional domains."""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_au", "domain": ".google.com.au"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com.au"},
|
|
{"name": "HSID", "value": "hsid_jp", "domain": ".google.co.jp"},
|
|
{"name": "SSID", "value": "ssid_de", "domain": ".google.de"},
|
|
]
|
|
}
|
|
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
|
|
assert cookies["SID"] == "sid_au"
|
|
assert cookies["HSID"] == "hsid_jp"
|
|
assert cookies["SSID"] == "ssid_de"
|
|
|
|
def test_prefers_primary_domain_over_regional(self):
|
|
"""Test that .google.com cookie wins over regional domains.
|
|
|
|
Regression test for PR #34: When the same cookie name exists on both
|
|
.google.com and a regional domain (e.g., .google.com.sg), the .google.com
|
|
value should ALWAYS be used regardless of cookie order in the list.
|
|
"""
|
|
# Test case 1: base domain listed first
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_global", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
{"name": "SID", "value": "sid_regional", "domain": ".google.com.sg"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com.sg"},
|
|
]
|
|
}
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
assert cookies["SID"] == "sid_global", ".google.com should win (base first)"
|
|
|
|
# Test case 2: regional domain listed first (this was the bug scenario)
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_regional", "domain": ".google.com.sg"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com.sg"},
|
|
{"name": "SID", "value": "sid_global", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
]
|
|
}
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
assert cookies["SID"] == "sid_global", ".google.com should win (regional first)"
|
|
|
|
def test_regional_google_sid_outranks_sibling_product_sid(self):
|
|
"""Regional Google SID outranks YouTube SID when both are present.
|
|
|
|
Post-#360 the allowlist accepts sibling-product cookies, but the
|
|
priority ladder still prefers a regional Google SID over a YouTube
|
|
SID when both are seen, because YouTube falls into the unranked tier
|
|
(priority 0) and regional Google domains sit at priority 1.
|
|
"""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "youtube_sid", "domain": ".youtube.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".youtube.com"},
|
|
{"name": "SID", "value": "regional_sid", "domain": ".google.com.sg"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com.sg"},
|
|
]
|
|
}
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
assert cookies["SID"] == "regional_sid"
|
|
|
|
def test_cookie_extraction_order_independent(self):
|
|
"""Test that cookie extraction is deterministic regardless of list order.
|
|
|
|
Regression test for PR #34: The original bug caused non-deterministic
|
|
behavior because Python dict's "last one wins" behavior meant the result
|
|
depended on cookie iteration order.
|
|
|
|
This test verifies that all permutations produce the same result.
|
|
"""
|
|
from itertools import permutations
|
|
|
|
base_cookies = [
|
|
{"name": "SID", "value": "sid_base", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
{"name": "SID", "value": "sid_sg", "domain": ".google.com.sg"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com.sg"},
|
|
{"name": "SID", "value": "sid_de", "domain": ".google.de"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.de"},
|
|
]
|
|
|
|
results = set()
|
|
for ordering in permutations(base_cookies):
|
|
storage_state = {"cookies": list(ordering)}
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
results.add(cookies["SID"])
|
|
|
|
# All permutations should produce the same result: .google.com wins
|
|
assert results == {"sid_base"}, (
|
|
f"Extraction should be deterministic, but got different results: {results}"
|
|
)
|
|
|
|
def test_regional_only_uses_first_encountered(self):
|
|
"""Test behavior when only regional domains exist (no .google.com).
|
|
|
|
When .google.com is not present, we use whatever cookie we encounter.
|
|
This documents the expected fallback behavior.
|
|
"""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_sg", "domain": ".google.com.sg"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com.sg"},
|
|
{"name": "SID", "value": "sid_de", "domain": ".google.de"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.de"},
|
|
]
|
|
}
|
|
|
|
cookies = extract_cookies_from_storage(storage_state)
|
|
|
|
# Without .google.com, first encountered wins
|
|
assert cookies["SID"] == "sid_sg"
|
|
|
|
|
|
class TestLoadHttpxCookiesRegional:
|
|
"""Test load_httpx_cookies with regional Google domains."""
|
|
|
|
def test_loads_cookies_from_regional_domain(self, tmp_path):
|
|
"""Test loading httpx cookies from regional Google domain."""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_from_uk", "domain": ".google.co.uk"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.co.uk"},
|
|
{"name": "HSID", "value": "hsid_val", "domain": ".google.co.uk"},
|
|
]
|
|
}
|
|
|
|
storage_file = tmp_path / "storage.json"
|
|
storage_file.write_text(json.dumps(storage_state))
|
|
|
|
cookies = load_httpx_cookies(path=storage_file)
|
|
assert cookies.get("SID", domain=".google.co.uk") == "sid_from_uk"
|
|
|
|
def test_loads_cookies_from_all_regional_patterns(self, tmp_path):
|
|
"""Test loading httpx cookies from all regional patterns."""
|
|
# Test with .google.de (single ccTLD)
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "sid_de", "domain": ".google.de"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.de"},
|
|
]
|
|
}
|
|
storage_file = tmp_path / "storage.json"
|
|
storage_file.write_text(json.dumps(storage_state))
|
|
|
|
cookies = load_httpx_cookies(path=storage_file)
|
|
assert cookies.get("SID", domain=".google.de") == "sid_de"
|
|
|
|
|
|
class TestSiblingGoogleProductExtraction:
|
|
"""Cookie extraction behavior for sibling Google product domains.
|
|
|
|
The cookie-domain split enforces blast-radius reduction at
|
|
*extraction* time (``_build_google_cookie_domains`` defaults to
|
|
:data:`REQUIRED_COOKIE_DOMAINS`, so rookiepy never returns YouTube
|
|
cookies unless the user opts in). The runtime gate stays permissive
|
|
over the full union so that opted-in cookies survive every downstream
|
|
filter. This class pins the contract that the downstream
|
|
storage-state filters do *not* drop sibling-product cookies once they
|
|
have been extracted — neither for Google subdomains nor for opted-in
|
|
YouTube cookies.
|
|
"""
|
|
|
|
GOOGLE_SUBDOMAIN_SIBLINGS = [
|
|
"drive.google.com",
|
|
".drive.google.com",
|
|
"docs.google.com",
|
|
".docs.google.com",
|
|
"myaccount.google.com",
|
|
".myaccount.google.com",
|
|
"mail.google.com",
|
|
".mail.google.com",
|
|
]
|
|
YOUTUBE_DOMAINS = [
|
|
".youtube.com",
|
|
"youtube.com",
|
|
"accounts.youtube.com",
|
|
".accounts.youtube.com",
|
|
]
|
|
|
|
@pytest.mark.parametrize("domain", GOOGLE_SUBDOMAIN_SIBLINGS)
|
|
def test_extract_cookies_with_domains_keeps_google_subdomain_siblings(self, domain):
|
|
"""``extract_cookies_with_domains`` keeps Drive/Docs/myaccount/Mail cookies."""
|
|
storage_state = {
|
|
"cookies": [
|
|
# Required SID on .google.com so extraction doesn't fail
|
|
{"name": "SID", "value": "base_sid", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
{"name": "PRODUCT_TOKEN", "value": "sibling", "domain": domain},
|
|
]
|
|
}
|
|
cookie_map = extract_cookies_with_domains(storage_state)
|
|
assert ("PRODUCT_TOKEN", domain, "/") in cookie_map
|
|
assert cookie_map[("PRODUCT_TOKEN", domain, "/")] == "sibling"
|
|
|
|
@pytest.mark.parametrize("domain", YOUTUBE_DOMAINS)
|
|
def test_extract_cookies_with_domains_keeps_opted_in_youtube(self, domain):
|
|
"""Contract: ``extract_cookies_with_domains`` keeps YouTube cookies.
|
|
|
|
Blast radius is reduced at extraction time (rookiepy does not
|
|
return YouTube cookies by default). When a user has opted in via
|
|
``--include-domains=youtube`` and the storage_state contains a
|
|
YouTube cookie, this filter must keep it so the opt-in is
|
|
observable end-to-end.
|
|
"""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "base_sid", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
{"name": "YT_TOKEN", "value": "yt", "domain": domain},
|
|
]
|
|
}
|
|
cookie_map = extract_cookies_with_domains(storage_state)
|
|
assert ("YT_TOKEN", domain, "/") in cookie_map
|
|
assert cookie_map[("YT_TOKEN", domain, "/")] == "yt"
|
|
|
|
@pytest.mark.parametrize("domain", GOOGLE_SUBDOMAIN_SIBLINGS)
|
|
def test_load_httpx_cookies_keeps_google_subdomain_siblings(self, tmp_path, domain):
|
|
"""``load_httpx_cookies`` accepts Google-subdomain sibling cookies."""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "base_sid", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
{"name": "PRODUCT_TOKEN", "value": "sibling", "domain": domain},
|
|
]
|
|
}
|
|
storage_file = tmp_path / "storage.json"
|
|
storage_file.write_text(json.dumps(storage_state))
|
|
|
|
cookies = load_httpx_cookies(path=storage_file)
|
|
assert cookies.get("PRODUCT_TOKEN", domain=domain) == "sibling"
|
|
|
|
@pytest.mark.parametrize("domain", YOUTUBE_DOMAINS)
|
|
def test_load_httpx_cookies_keeps_opted_in_youtube(self, tmp_path, domain):
|
|
"""``load_httpx_cookies`` keeps opted-in YouTube cookies.
|
|
|
|
Blast radius is reduced at extraction time. The runtime gate
|
|
(and therefore this loader) is permissive over the union so
|
|
``--include-domains=youtube`` cookies survive download/refresh
|
|
operations.
|
|
"""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "base_sid", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
{"name": "YT_TOKEN", "value": "yt", "domain": domain},
|
|
]
|
|
}
|
|
storage_file = tmp_path / "storage.json"
|
|
storage_file.write_text(json.dumps(storage_state))
|
|
|
|
cookies = load_httpx_cookies(path=storage_file)
|
|
assert cookies.get("YT_TOKEN", domain=domain) == "yt"
|
|
|
|
@pytest.mark.parametrize("domain", GOOGLE_SUBDOMAIN_SIBLINGS)
|
|
def test_convert_rookiepy_keeps_google_subdomain_siblings(self, domain):
|
|
"""rookiepy → storage_state conversion keeps Google-subdomain siblings."""
|
|
raw = [
|
|
{
|
|
"domain": domain,
|
|
"name": "PRODUCT_TOKEN",
|
|
"value": "sibling",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
}
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert len(result["cookies"]) == 1
|
|
assert result["cookies"][0]["domain"] == domain
|
|
|
|
@pytest.mark.parametrize("domain", YOUTUBE_DOMAINS)
|
|
def test_convert_rookiepy_keeps_opted_in_youtube(self, domain):
|
|
"""rookiepy → storage_state keeps opted-in YouTube cookies.
|
|
|
|
Blast radius is reduced at extraction time by
|
|
``_build_google_cookie_domains`` (the rookiepy domain filter).
|
|
Once a YouTube cookie has been extracted (because the user
|
|
opted in), this converter must keep it so the opt-in is
|
|
observable end-to-end.
|
|
"""
|
|
raw = [
|
|
{
|
|
"domain": domain,
|
|
"name": "YT_TOKEN",
|
|
"value": "yt",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
}
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert len(result["cookies"]) == 1
|
|
assert result["cookies"][0]["domain"] == domain
|
|
assert result["cookies"][0]["name"] == "YT_TOKEN"
|
|
|
|
def test_strict_allowlisted_domains_still_work(self):
|
|
"""Regression: pre-existing strict-allowlisted domains keep working.
|
|
|
|
Ensures the unification didn't accidentally drop any of the original
|
|
canonical NotebookLM auth domains.
|
|
"""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "v1", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
{"name": "HSID", "value": "v2", "domain": ".google.com"},
|
|
{"name": "OSID", "value": "v3", "domain": "notebooklm.google.com"},
|
|
{"name": "OSID2", "value": "v4", "domain": ".notebooklm.google.com"},
|
|
{"name": "ACC", "value": "v5", "domain": "accounts.google.com"},
|
|
{"name": "ACC2", "value": "v6", "domain": ".accounts.google.com"},
|
|
{"name": "MEDIA", "value": "v7", "domain": ".googleusercontent.com"},
|
|
]
|
|
}
|
|
cookie_map = extract_cookies_with_domains(storage_state)
|
|
assert ("SID", ".google.com", "/") in cookie_map
|
|
assert ("HSID", ".google.com", "/") in cookie_map
|
|
assert ("OSID", "notebooklm.google.com", "/") in cookie_map
|
|
assert ("OSID2", ".notebooklm.google.com", "/") in cookie_map
|
|
assert ("ACC", "accounts.google.com", "/") in cookie_map
|
|
assert ("ACC2", ".accounts.google.com", "/") in cookie_map
|
|
assert ("MEDIA", ".googleusercontent.com", "/") in cookie_map
|
|
|
|
def test_unified_filter_rejects_unrelated_domains(self):
|
|
"""Regression: cookies from unrelated domains are still rejected."""
|
|
storage_state = {
|
|
"cookies": [
|
|
{"name": "SID", "value": "v1", "domain": ".google.com"},
|
|
{"name": "__Secure-1PSIDTS", "value": "test_1psidts", "domain": ".google.com"},
|
|
{"name": "EVIL", "value": "x", "domain": ".evil.com"},
|
|
{"name": "EVIL2", "value": "y", "domain": ".not-google.com"},
|
|
{"name": "EVIL3", "value": "z", "domain": ".evil-google.com"},
|
|
{"name": "EVIL4", "value": "w", "domain": ".not-youtube.com"},
|
|
]
|
|
}
|
|
cookie_map = extract_cookies_with_domains(storage_state)
|
|
kept_names = {name for name, _, _ in cookie_map}
|
|
assert kept_names == {"SID", "__Secure-1PSIDTS"}
|
|
|
|
|
|
class TestPathAwareCookieIdentity:
|
|
"""Issue #369: ``(name, domain, path)`` is the cookie identity per RFC 6265
|
|
§5.3. Two cookies sharing ``(name, domain)`` at different paths must coexist
|
|
end-to-end across the load/save APIs, and ``_find_cookie_for_storage`` must
|
|
not return a sibling on a different path.
|
|
"""
|
|
|
|
# Shared fixtures: the OSID path-sibling pair under test is identical
|
|
# across cases except for the values and any extra Playwright-shaped
|
|
# fields (httpOnly/secure/expires) the load/save round trip needs.
|
|
|
|
_OSID_DOMAIN = "accounts.google.com"
|
|
|
|
def _required_cookies(self) -> list[dict[str, Any]]:
|
|
return [
|
|
{"name": "SID", "value": "sid", "domain": ".google.com", "path": "/"},
|
|
{
|
|
"name": "__Secure-1PSIDTS",
|
|
"value": "tts",
|
|
"domain": ".google.com",
|
|
"path": "/",
|
|
},
|
|
]
|
|
|
|
def _osid_siblings(
|
|
self,
|
|
*,
|
|
root_value: str = "root",
|
|
u0_value: str = "u0",
|
|
extras: dict[str, Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return an ``OSID@/`` + ``OSID@/u/0/`` pair sharing optional fields."""
|
|
base = extras or {}
|
|
return [
|
|
{
|
|
"name": "OSID",
|
|
"value": root_value,
|
|
"domain": self._OSID_DOMAIN,
|
|
"path": "/",
|
|
**base,
|
|
},
|
|
{
|
|
"name": "OSID",
|
|
"value": u0_value,
|
|
"domain": self._OSID_DOMAIN,
|
|
"path": "/u/0/",
|
|
**base,
|
|
},
|
|
]
|
|
|
|
def _storage_state(self, osids: list[dict[str, Any]]) -> dict[str, Any]:
|
|
return {"cookies": [*self._required_cookies(), *osids]}
|
|
|
|
def test_extract_cookies_with_domains_keeps_path_siblings(self):
|
|
"""Two cookies sharing name+domain at distinct paths both survive
|
|
extraction (pre-#369 the second one silently shadowed the first)."""
|
|
cookie_map = extract_cookies_with_domains(self._storage_state(self._osid_siblings()))
|
|
assert cookie_map[("OSID", self._OSID_DOMAIN, "/")] == "root"
|
|
assert cookie_map[("OSID", self._OSID_DOMAIN, "/u/0/")] == "u0"
|
|
|
|
def test_build_httpx_cookies_keeps_path_siblings(self, tmp_path):
|
|
"""The load-side dedup in ``build_httpx_cookies_from_storage`` keys
|
|
on ``(name, domain, path)`` so both path-siblings land in the jar."""
|
|
storage = tmp_path / "storage_state.json"
|
|
storage.write_text(json.dumps(self._storage_state(self._osid_siblings())))
|
|
|
|
jar = build_httpx_cookies_from_storage(storage)
|
|
observed = {(c.name, c.domain, c.path): c.value for c in jar.jar if c.name == "OSID"}
|
|
assert observed == {
|
|
("OSID", self._OSID_DOMAIN, "/"): "root",
|
|
("OSID", self._OSID_DOMAIN, "/u/0/"): "u0",
|
|
}
|
|
|
|
def test_round_trip_load_fetch_save_preserves_siblings(self, tmp_path):
|
|
"""A synthetic ``OSID@/`` + ``OSID@/u/0/`` storage_state survives a
|
|
load → fetch (no-op mutation) → save round trip with **both** entries
|
|
intact. Closes the issue's acceptance criterion."""
|
|
storage = tmp_path / "storage_state.json"
|
|
storage.write_text(
|
|
json.dumps(
|
|
self._storage_state(
|
|
self._osid_siblings(
|
|
extras={"httpOnly": False, "secure": False, "expires": -1},
|
|
)
|
|
)
|
|
)
|
|
)
|
|
|
|
jar = build_httpx_cookies_from_storage(storage)
|
|
snapshot = snapshot_cookie_jar(jar)
|
|
# No mutation; save_cookies_to_storage is a no-op delta.
|
|
save_cookies_to_storage(jar, storage, original_snapshot=snapshot)
|
|
|
|
reloaded = json.loads(storage.read_text())
|
|
osids = [c for c in reloaded["cookies"] if c["name"] == "OSID"]
|
|
assert {(c["domain"], c["path"], c["value"]) for c in osids} == {
|
|
(self._OSID_DOMAIN, "/", "root"),
|
|
(self._OSID_DOMAIN, "/u/0/", "u0"),
|
|
}
|
|
|
|
def test_legacy_full_merge_preserves_path_siblings(self, tmp_path):
|
|
"""The legacy ``original_snapshot=None`` merge keys ``cookies_by_key`` /
|
|
``stored_keys`` on ``(name, domain, path)`` so a refreshed cookie at
|
|
path ``/`` does not overwrite a sibling at ``/u/0/`` and vice versa."""
|
|
import warnings
|
|
|
|
storage = tmp_path / "storage_state.json"
|
|
storage.write_text(
|
|
json.dumps(
|
|
self._storage_state(
|
|
self._osid_siblings(
|
|
root_value="root_old",
|
|
u0_value="u0_old",
|
|
extras={"expires": -1},
|
|
)
|
|
)
|
|
)
|
|
)
|
|
|
|
jar = httpx.Cookies()
|
|
jar.set("SID", "sid", domain=".google.com", path="/")
|
|
jar.set("__Secure-1PSIDTS", "tts", domain=".google.com", path="/")
|
|
jar.set("OSID", "root_new", domain="accounts.google.com", path="/")
|
|
jar.set("OSID", "u0_new", domain="accounts.google.com", path="/u/0/")
|
|
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
save_cookies_to_storage(jar, storage)
|
|
|
|
reloaded = json.loads(storage.read_text())
|
|
osids = [c for c in reloaded["cookies"] if c["name"] == "OSID"]
|
|
observed = {(c["domain"], c["path"], c["value"]) for c in osids}
|
|
# Both siblings refreshed independently — pre-#369 the legacy merge
|
|
# would have shadowed one with the other because cookies_by_key was
|
|
# keyed on (name, domain).
|
|
assert observed == {
|
|
(self._OSID_DOMAIN, "/", "root_new"),
|
|
(self._OSID_DOMAIN, "/u/0/", "u0_new"),
|
|
}
|
|
|
|
def test_normalize_cookie_map_widens_legacy_2tuple_input(self):
|
|
"""Pre-#369 callers built ``{(name, domain): value}`` dicts by hand;
|
|
those keep working — :func:`normalize_cookie_map` widens the missing
|
|
path to ``/`` so the rest of the pipeline sees the canonical shape."""
|
|
from notebooklm._auth.cookies import normalize_cookie_map
|
|
|
|
result = normalize_cookie_map(
|
|
{
|
|
("SID", ".google.com"): "abc",
|
|
("OSID", "accounts.google.com"): "xyz",
|
|
}
|
|
)
|
|
assert result == {
|
|
("SID", ".google.com", "/"): "abc",
|
|
("OSID", "accounts.google.com", "/"): "xyz",
|
|
}
|
|
|
|
def test_normalize_cookie_map_warns_on_malformed_tuple(self, caplog):
|
|
"""A malformed tuple key would otherwise vanish into a downstream
|
|
'missing required cookies' error. Surface it via ``logger.warning``."""
|
|
import logging
|
|
|
|
from notebooklm._auth.cookies import normalize_cookie_map
|
|
|
|
with caplog.at_level(logging.WARNING, logger="notebooklm.auth"):
|
|
result = normalize_cookie_map(
|
|
{("SID", ".google.com", "/", "extra"): "abc"} # type: ignore[dict-item]
|
|
)
|
|
assert result == {}
|
|
assert any("malformed cookie key" in rec.message for rec in caplog.records)
|
|
|
|
def test_update_cookie_input_preserves_legacy_2tuple_shape(self):
|
|
"""A caller that originally passed a legacy 2-tuple dict must observe
|
|
the same shape after an in-place refresh — internal widening should
|
|
not bleed 3-tuple keys into their data structure."""
|
|
from notebooklm.auth import _update_cookie_input
|
|
|
|
target: dict[Any, str] = {
|
|
("SID", ".google.com"): "old_sid",
|
|
("OSID", "accounts.google.com"): "old_osid",
|
|
}
|
|
fresh: dict[tuple[str, str, str], str] = {
|
|
("SID", ".google.com", "/"): "new_sid",
|
|
("OSID", "accounts.google.com", "/"): "new_osid",
|
|
}
|
|
|
|
_update_cookie_input(target, fresh)
|
|
|
|
assert target == {
|
|
("SID", ".google.com"): "new_sid",
|
|
("OSID", "accounts.google.com"): "new_osid",
|
|
}
|
|
|
|
def test_find_cookie_for_storage_picks_path_sibling(self):
|
|
"""``_find_cookie_for_storage`` must discriminate on path: a stored
|
|
``(OSID, accounts.google.com, /u/0/)`` row must NOT be refreshed with
|
|
the ``OSID`` value from path ``/``. Pre-#369 the path was ignored and
|
|
either sibling could be returned."""
|
|
from notebooklm.auth import _find_cookie_for_storage
|
|
|
|
# Two in-memory cookies sharing (name, domain) at distinct paths
|
|
# coexist in a single ``http.cookiejar`` because its internal index is
|
|
# ``(domain, path, name)`` — exactly the identity #369 makes first-class.
|
|
jar = httpx.Cookies()
|
|
jar.set("OSID", "root", domain=self._OSID_DOMAIN, path="/")
|
|
jar.set("OSID", "u0", domain=self._OSID_DOMAIN, path="/u/0/")
|
|
cookies_by_key = {(c.name, c.domain, c.path or "/"): c for c in jar.jar}
|
|
|
|
# Looking up the /u/0/ key must return the /u/0/ cookie, not the / one.
|
|
found = _find_cookie_for_storage(
|
|
cookies_by_key,
|
|
("OSID", self._OSID_DOMAIN, "/u/0/"),
|
|
stored_value="stale_u0",
|
|
)
|
|
assert found is not None
|
|
assert found.value == "u0"
|
|
assert found.path == "/u/0/"
|
|
|
|
|
|
class TestRookiepyDomainsCoverage:
|
|
"""Confirm ``_login_with_browser_cookies`` knows about every sibling product.
|
|
|
|
After the fix the rookiepy ``domains`` list defaults to REQUIRED only, but
|
|
the ``--include-domains`` opt-in still has to cover every sibling
|
|
product. Pin that every known sibling label resolves to at least one
|
|
domain, so future contributors can't forget to wire up a new label.
|
|
"""
|
|
|
|
def test_every_optional_label_has_domains(self):
|
|
from notebooklm.auth import OPTIONAL_COOKIE_DOMAINS_BY_LABEL
|
|
|
|
for label, domains in OPTIONAL_COOKIE_DOMAINS_BY_LABEL.items():
|
|
assert domains, f"label {label!r} maps to an empty domain set"
|
|
|
|
def test_allowlist_union_still_covers_legacy_siblings(self):
|
|
"""ALLOWED_COOKIE_DOMAINS (union) covers every legacy sibling domain.
|
|
|
|
External callers that read the union constant for documentation /
|
|
validation purposes (e.g. ``cli.session`` historically) keep
|
|
seeing the same domains. Only the runtime gate has tightened.
|
|
"""
|
|
from notebooklm._auth.cookie_policy import ALLOWED_COOKIE_DOMAINS
|
|
|
|
for domain in (
|
|
".youtube.com",
|
|
"accounts.youtube.com",
|
|
"drive.google.com",
|
|
"docs.google.com",
|
|
"myaccount.google.com",
|
|
):
|
|
assert domain in ALLOWED_COOKIE_DOMAINS, (
|
|
f"{domain!r} must be in ALLOWED_COOKIE_DOMAINS (union) so "
|
|
"callers that read the legacy constant still see it"
|
|
)
|
|
|
|
|
|
class TestConvertRookiepyCookies:
|
|
"""Test conversion from rookiepy cookie dicts to storage_state.json format."""
|
|
|
|
def test_converts_basic_cookie(self):
|
|
"""Single cookie is converted to storage_state format."""
|
|
raw = [
|
|
{
|
|
"domain": ".google.com",
|
|
"name": "SID",
|
|
"value": "abc",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": 1234567890,
|
|
"http_only": False,
|
|
}
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert result["cookies"][0] == {
|
|
"name": "SID",
|
|
"value": "abc",
|
|
"domain": ".google.com",
|
|
"path": "/",
|
|
"expires": 1234567890,
|
|
"httpOnly": False,
|
|
"secure": True,
|
|
"sameSite": "None",
|
|
}
|
|
assert result["origins"] == []
|
|
|
|
def test_none_expires_becomes_minus_one(self):
|
|
"""rookiepy uses None for session cookies; storage_state uses -1."""
|
|
raw = [
|
|
{
|
|
"domain": ".google.com",
|
|
"name": "SID",
|
|
"value": "x",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
}
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert result["cookies"][0]["expires"] == -1
|
|
|
|
def test_filters_non_google_domains(self):
|
|
"""Non-Google domains are dropped."""
|
|
raw = [
|
|
{
|
|
"domain": ".google.com",
|
|
"name": "SID",
|
|
"value": "x",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
},
|
|
{
|
|
"domain": "evil.com",
|
|
"name": "TRACK",
|
|
"value": "y",
|
|
"path": "/",
|
|
"secure": False,
|
|
"expires": None,
|
|
"http_only": False,
|
|
},
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert len(result["cookies"]) == 1
|
|
assert result["cookies"][0]["name"] == "SID"
|
|
|
|
def test_snake_to_camel_case(self):
|
|
"""http_only (rookiepy) → httpOnly (storage_state)."""
|
|
raw = [
|
|
{
|
|
"domain": ".google.com",
|
|
"name": "X",
|
|
"value": "y",
|
|
"path": "/",
|
|
"secure": False,
|
|
"expires": None,
|
|
"http_only": True,
|
|
}
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert "http_only" not in result["cookies"][0]
|
|
assert result["cookies"][0]["httpOnly"] is True
|
|
|
|
def test_empty_list(self):
|
|
"""Empty cookie list returns empty structure."""
|
|
assert convert_rookiepy_cookies_to_storage_state([]) == {
|
|
"cookies": [],
|
|
"origins": [],
|
|
}
|
|
|
|
def test_regional_google_domain_included(self):
|
|
"""Regional domains like .google.co.uk are kept."""
|
|
raw = [
|
|
{
|
|
"domain": ".google.co.uk",
|
|
"name": "SID",
|
|
"value": "x",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
}
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert len(result["cookies"]) == 1
|
|
|
|
def test_notebooklm_subdomain_included(self):
|
|
"""Playwright-style NotebookLM subdomain cookies are kept."""
|
|
raw = [
|
|
{
|
|
"domain": ".notebooklm.google.com",
|
|
"name": "OSID",
|
|
"value": "x",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
}
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert len(result["cookies"]) == 1
|
|
assert result["cookies"][0]["domain"] == ".notebooklm.google.com"
|
|
assert result["cookies"][0]["name"] == "OSID"
|
|
|
|
def test_sibling_google_product_subdomains_kept(self):
|
|
"""Auth conversion keeps sibling Google subdomain cookies (after the fix).
|
|
|
|
The cookie-domain split narrowed the runtime gate from the union to REQUIRED, but the
|
|
``.google.com`` suffix branch of :func:`_is_allowed_cookie_domain`
|
|
still accepts cookies on Drive / Docs / myaccount / Mail. Only
|
|
``youtube.com`` (which is not a subdomain of ``.google.com``) is
|
|
now dropped by default — see
|
|
:class:`TestSiblingGoogleProductExtraction` for that contract.
|
|
"""
|
|
raw = [
|
|
{
|
|
"domain": domain,
|
|
"name": "SID",
|
|
"value": "v",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
}
|
|
for domain in (
|
|
".mail.google.com",
|
|
".drive.google.com",
|
|
".docs.google.com",
|
|
".myaccount.google.com",
|
|
)
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
kept_domains = {c["domain"] for c in result["cookies"]}
|
|
assert kept_domains == {
|
|
".mail.google.com",
|
|
".drive.google.com",
|
|
".docs.google.com",
|
|
".myaccount.google.com",
|
|
}
|
|
|
|
def test_unrelated_domains_still_filtered(self):
|
|
"""Cookies from non-Google domains are still dropped."""
|
|
raw = [
|
|
{
|
|
"domain": ".evil.com",
|
|
"name": "SID",
|
|
"value": "x",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
},
|
|
{
|
|
"domain": ".not-google.com",
|
|
"name": "SID",
|
|
"value": "x",
|
|
"path": "/",
|
|
"secure": True,
|
|
"expires": None,
|
|
"http_only": False,
|
|
},
|
|
]
|
|
result = convert_rookiepy_cookies_to_storage_state(raw)
|
|
assert result == {"cookies": [], "origins": []}
|
|
|
|
|
|
class TestMinimumRequiredCookies:
|
|
"""Test minimum required cookies constant."""
|
|
|
|
def test_minimum_required_cookies_contains_sid(self):
|
|
"""Test MINIMUM_REQUIRED_COOKIES contains SID."""
|
|
from notebooklm._auth.cookie_policy import MINIMUM_REQUIRED_COOKIES
|
|
|
|
assert "SID" in MINIMUM_REQUIRED_COOKIES
|
|
|
|
|
|
class TestAllowedCookieDomains:
|
|
"""Test cookie-domain constants (REQUIRED, OPTIONAL, ALLOWED union)."""
|
|
|
|
def test_allowed_cookie_domains_union(self):
|
|
"""``ALLOWED_COOKIE_DOMAINS`` is the union of REQUIRED + OPTIONAL.
|
|
|
|
Pins the union so external code that still imports the old constant
|
|
keeps working. Internal callers should prefer the explicit
|
|
REQUIRED/OPTIONAL constants — see the cookie-domain split
|
|
migration note in ``src/notebooklm/auth.py``.
|
|
"""
|
|
from notebooklm._auth.cookie_policy import ALLOWED_COOKIE_DOMAINS
|
|
from notebooklm.auth import (
|
|
OPTIONAL_COOKIE_DOMAINS,
|
|
REQUIRED_COOKIE_DOMAINS,
|
|
)
|
|
|
|
assert ALLOWED_COOKIE_DOMAINS == REQUIRED_COOKIE_DOMAINS | OPTIONAL_COOKIE_DOMAINS
|
|
|
|
def test_required_cookie_domains_preserve_normalization_variants(self):
|
|
"""REQUIRED keeps both host and dotted variants of each domain.
|
|
|
|
Codex caution from the cookie-domain split design: http.cookiejar may normalize
|
|
``Domain=accounts.google.com`` to ``.accounts.google.com``. If
|
|
REQUIRED only contained one variant, the next extraction would
|
|
silently drop the cookie. Pin both forms here.
|
|
"""
|
|
from notebooklm.auth import REQUIRED_COOKIE_DOMAINS
|
|
|
|
# Required core auth + drive ingest set.
|
|
expected = {
|
|
".google.com",
|
|
"google.com",
|
|
".notebooklm.google.com",
|
|
"notebooklm.google.com",
|
|
".notebooklm.cloud.google.com",
|
|
"notebooklm.cloud.google.com",
|
|
".googleusercontent.com",
|
|
"accounts.google.com",
|
|
".accounts.google.com",
|
|
"drive.google.com",
|
|
".drive.google.com",
|
|
}
|
|
missing = expected - REQUIRED_COOKIE_DOMAINS
|
|
assert not missing, f"REQUIRED_COOKIE_DOMAINS is missing: {missing}"
|
|
|
|
def test_required_is_frozenset(self):
|
|
"""REQUIRED must be a frozenset so it cannot be mutated at runtime."""
|
|
from notebooklm._auth.cookie_policy import ALLOWED_COOKIE_DOMAINS
|
|
from notebooklm.auth import (
|
|
OPTIONAL_COOKIE_DOMAINS,
|
|
REQUIRED_COOKIE_DOMAINS,
|
|
)
|
|
|
|
assert isinstance(REQUIRED_COOKIE_DOMAINS, frozenset)
|
|
assert isinstance(OPTIONAL_COOKIE_DOMAINS, frozenset)
|
|
assert isinstance(ALLOWED_COOKIE_DOMAINS, frozenset)
|
|
|
|
def test_optional_cookie_domains_label_partition(self):
|
|
"""OPTIONAL labels partition the OPTIONAL domain set exactly."""
|
|
from notebooklm.auth import (
|
|
OPTIONAL_COOKIE_DOMAINS,
|
|
OPTIONAL_COOKIE_DOMAINS_BY_LABEL,
|
|
)
|
|
|
|
union = frozenset().union(*OPTIONAL_COOKIE_DOMAINS_BY_LABEL.values())
|
|
assert union == OPTIONAL_COOKIE_DOMAINS
|
|
|
|
def test_required_and_optional_are_disjoint(self):
|
|
"""No domain appears in both REQUIRED and OPTIONAL.
|
|
|
|
Otherwise the runtime gate would be ambiguous and the
|
|
``--include-domains`` opt-in would have no observable effect for
|
|
the overlapping domain.
|
|
"""
|
|
from notebooklm.auth import OPTIONAL_COOKIE_DOMAINS, REQUIRED_COOKIE_DOMAINS
|
|
|
|
assert REQUIRED_COOKIE_DOMAINS.isdisjoint(OPTIONAL_COOKIE_DOMAINS)
|
|
|
|
|
|
# =============================================================================
|
|
# REGIONAL GOOGLE DOMAIN TESTS (Issue #20 fix)
|
|
# =============================================================================
|