Files
teng-lin--notebooklm-py/tests/unit/test_profile_atomic_write.py
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

438 lines
17 KiB
Python

"""Unit tests for unified atomic profile-state write (P1-20).
The pre-P1-20 layout stored Google auth state across two files:
* ``storage_state.json`` — Playwright cookie/origin state.
* ``context.json`` — sibling JSON with ``{"account": {"authuser", "email"}}``
and CLI notebook/conversation context.
Each file was atomically written on its own. Between the two writes there was
a window in which an external reader (e.g. a long-running ``notebooklm chat``
in a sibling shell) could see either the new cookies bundled with the old
account metadata, or vice versa. Tier-9 P1-20 closes this by bundling the
``account`` record INTO ``storage_state.json`` under a ``notebooklm``
namespace key:
{
"cookies": [...],
"origins": [...],
"notebooklm": {"version": 1, "account": {"authuser": 1, "email": "..."}}
}
A single ``atomic_write_json`` is now the only commit point for the
(cookies, account) pair. ``context.json`` keeps holding non-account CLI
state (``notebook_id``, ``conversation_id``); the account key, if still
present from legacy installs, is migrated lazily on next write.
This module covers:
1. Round-trip of the new unified record.
2. Migration: a legacy two-file fixture reads cleanly under the new reader.
3. Migration: writing account metadata after a legacy read drops the
``account`` key from ``context.json`` (preserving other CLI state).
4. Torn-write fault injection: if ``storage_state.json`` write fails after
cookies + account were serialized into the same temp file, the original
on-disk file is preserved untouched (no half-written state).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from notebooklm._auth.account import (
clear_account_metadata,
get_account_email_for_storage,
get_authuser_for_storage,
read_account_metadata,
write_account_metadata,
)
def _write_storage_state(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload), encoding="utf-8")
def _read_storage_state(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _context_path(storage_path: Path) -> Path:
return storage_path.with_name("context.json")
def test_write_account_metadata_lands_inline_in_storage_state(tmp_path: Path) -> None:
"""The post-P1-20 writer puts account inside storage_state.json."""
storage_path = tmp_path / "storage_state.json"
_write_storage_state(
storage_path,
{
"cookies": [{"name": "SID", "value": "v", "domain": ".google.com", "path": "/"}],
"origins": [],
},
)
write_account_metadata(storage_path, authuser=1, email="alice@example.com")
payload = _read_storage_state(storage_path)
assert payload["notebooklm"]["version"] == 1
assert payload["notebooklm"]["account"] == {"authuser": 1, "email": "alice@example.com"}
def test_read_account_metadata_prefers_in_band_record(tmp_path: Path) -> None:
storage_path = tmp_path / "storage_state.json"
_write_storage_state(
storage_path,
{
"cookies": [],
"origins": [],
"notebooklm": {
"version": 1,
"account": {"authuser": 2, "email": "bob@example.com"},
},
},
)
assert get_authuser_for_storage(storage_path) == 2
assert get_account_email_for_storage(storage_path) == "bob@example.com"
def test_legacy_two_file_fixture_reads_cleanly(tmp_path: Path) -> None:
"""ACCEPTANCE-CRITICAL: existing two-file profile loads under new reader.
If this migration test breaks for any existing user, they will lose their
account binding (authuser/email) on the next CLI run — Tier-9 P1-20 PR
body must surface this.
"""
storage_path = tmp_path / "storage_state.json"
_write_storage_state(
storage_path,
{
"cookies": [{"name": "SID", "value": "v", "domain": ".google.com", "path": "/"}],
"origins": [],
},
)
# Legacy sibling context.json with account but no in-band record.
_context_path(storage_path).write_text(
json.dumps(
{
"account": {"authuser": 3, "email": "charlie@example.com"},
"notebook_id": "nb-123",
}
),
encoding="utf-8",
)
assert get_authuser_for_storage(storage_path) == 3
assert get_account_email_for_storage(storage_path) == "charlie@example.com"
assert read_account_metadata(storage_path) == {
"authuser": 3,
"email": "charlie@example.com",
}
def test_migration_on_write_removes_legacy_account_key_only(tmp_path: Path) -> None:
"""After upgrade write, ``context.json`` keeps non-account state.
``context.json`` also holds ``notebook_id`` / ``conversation_id`` —
migration must NOT clobber those.
"""
storage_path = tmp_path / "storage_state.json"
_write_storage_state(storage_path, {"cookies": [], "origins": []})
_context_path(storage_path).write_text(
json.dumps(
{
"account": {"authuser": 4, "email": "dana@example.com"},
"notebook_id": "nb-456",
"conversation_id": "conv-789",
}
),
encoding="utf-8",
)
# Trigger a unified write; this should migrate the legacy record.
write_account_metadata(storage_path, authuser=5, email="erin@example.com")
in_band = _read_storage_state(storage_path)["notebooklm"]["account"]
assert in_band == {"authuser": 5, "email": "erin@example.com"}
# Non-account context state preserved.
legacy = json.loads(_context_path(storage_path).read_text(encoding="utf-8"))
assert "account" not in legacy
assert legacy.get("notebook_id") == "nb-456"
assert legacy.get("conversation_id") == "conv-789"
def test_in_band_account_overrides_legacy_account(tmp_path: Path) -> None:
"""When both forms exist, in-band wins."""
storage_path = tmp_path / "storage_state.json"
_write_storage_state(
storage_path,
{
"cookies": [],
"origins": [],
"notebooklm": {
"version": 1,
"account": {"authuser": 7, "email": "new@example.com"},
},
},
)
_context_path(storage_path).write_text(
json.dumps({"account": {"authuser": 1, "email": "stale@example.com"}}),
encoding="utf-8",
)
assert get_authuser_for_storage(storage_path) == 7
assert get_account_email_for_storage(storage_path) == "new@example.com"
def test_clear_account_metadata_clears_in_band(tmp_path: Path) -> None:
storage_path = tmp_path / "storage_state.json"
_write_storage_state(
storage_path,
{
"cookies": [],
"origins": [],
"notebooklm": {
"version": 1,
"account": {"authuser": 9, "email": "zoe@example.com"},
},
},
)
clear_account_metadata(storage_path)
# Either the namespace is gone, or its account is gone — either way the
# reader reports no account. The file itself remains valid JSON.
_read_storage_state(storage_path) # sanity-check the file still parses
assert read_account_metadata(storage_path) == {}
assert get_authuser_for_storage(storage_path) == 0
assert get_account_email_for_storage(storage_path) is None
def test_clear_account_metadata_clears_legacy_two_file(tmp_path: Path) -> None:
"""Backward compat: clearing still removes legacy ``context.json`` account."""
storage_path = tmp_path / "storage_state.json"
_write_storage_state(storage_path, {"cookies": [], "origins": []})
_context_path(storage_path).write_text(
json.dumps(
{
"account": {"authuser": 8, "email": "leah@example.com"},
"notebook_id": "nb-keep",
}
),
encoding="utf-8",
)
clear_account_metadata(storage_path)
# Non-account context preserved.
legacy = json.loads(_context_path(storage_path).read_text(encoding="utf-8"))
assert "account" not in legacy
assert legacy.get("notebook_id") == "nb-keep"
def test_torn_write_fault_injection_preserves_original(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ACCEPTANCE-CRITICAL: writer crash between cookies+metadata is recoverable.
If we crash during the unified write, the original on-disk
``storage_state.json`` must remain valid and unchanged — no half-written
state mixing new cookies with old account or vice versa.
"""
storage_path = tmp_path / "storage_state.json"
original_payload = {
"cookies": [{"name": "SID", "value": "old-v", "domain": ".google.com", "path": "/"}],
"origins": [],
"notebooklm": {
"version": 1,
"account": {"authuser": 1, "email": "original@example.com"},
},
}
_write_storage_state(storage_path, original_payload)
# Inject a crash mid-write: the next ``os.replace`` raises.
import notebooklm._atomic_io as atomic_io_mod
original_replace = atomic_io_mod.os.replace
def _boom(src: Any, dst: Any) -> None:
raise OSError("simulated crash during atomic replace")
monkeypatch.setattr(atomic_io_mod.os, "replace", _boom)
with pytest.raises(OSError, match="simulated crash"):
write_account_metadata(storage_path, authuser=99, email="never@example.com")
monkeypatch.setattr(atomic_io_mod.os, "replace", original_replace)
# Original file untouched: reader sees the OLD record consistently.
assert _read_storage_state(storage_path) == original_payload
assert get_authuser_for_storage(storage_path) == 1
assert get_account_email_for_storage(storage_path) == "original@example.com"
# No torn-write temp files leaked beside the storage file. The filelock
# sentinel (``.storage_state.json.lock``) is expected to persist — filelock
# >= 3.29 no longer unlinks it on release — and is not a torn-write leak.
leftover_temps = [
p for p in tmp_path.glob(".storage_state.json.*") if p.name != ".storage_state.json.lock"
]
assert leftover_temps == [], f"Temp file leaked: {leftover_temps}"
def test_torn_write_reader_never_sees_mixed_state(tmp_path: Path) -> None:
"""Property: at any observation point, reader sees old XOR new — never mixed.
Atomicity comes from ``atomic_write_json``'s single ``os.replace``: until
the rename commits, the reader sees only the previous on-disk version.
This test asserts the contract is exercised by the unified write path —
after a successful write, both cookies and account come from the same
commit; if the write fails, neither rolls forward.
"""
storage_path = tmp_path / "storage_state.json"
# Round 1: write old state.
write_account_metadata(storage_path, authuser=10, email="round1@example.com")
# Sanity: the file now exists with the round-1 record.
payload_1 = _read_storage_state(storage_path)
assert payload_1["notebooklm"]["account"]["email"] == "round1@example.com"
# Round 2: overwrite with a new account record.
write_account_metadata(storage_path, authuser=20, email="round2@example.com")
payload_2 = _read_storage_state(storage_path)
# The reader sees exactly one record — never a merge.
assert payload_2["notebooklm"]["account"] == {
"authuser": 20,
"email": "round2@example.com",
}
# The non-account file structure round-trips unchanged.
assert payload_2.get("cookies") == payload_1.get("cookies")
def test_unified_format_version_is_pinned_to_one(tmp_path: Path) -> None:
"""Pin the version number so any future bump is intentional."""
storage_path = tmp_path / "storage_state.json"
_write_storage_state(storage_path, {"cookies": [], "origins": []})
write_account_metadata(storage_path, authuser=1, email="v@example.com")
payload = _read_storage_state(storage_path)
assert payload["notebooklm"]["version"] == 1
def test_write_without_email_omits_email_field(tmp_path: Path) -> None:
"""Default-account login: authuser=0, no email — record omits email."""
storage_path = tmp_path / "storage_state.json"
_write_storage_state(storage_path, {"cookies": [], "origins": []})
write_account_metadata(storage_path, authuser=0, email=None)
payload = _read_storage_state(storage_path)
assert payload["notebooklm"]["account"] == {"authuser": 0}
def test_write_preserves_cookies_and_origins(tmp_path: Path) -> None:
"""Writing account metadata MUST NOT touch cookies / origins."""
storage_path = tmp_path / "storage_state.json"
cookies = [
{"name": "SID", "value": "v1", "domain": ".google.com", "path": "/"},
{"name": "HSID", "value": "v2", "domain": ".google.com", "path": "/"},
]
origins = [
{"origin": "https://notebooklm.google.com", "localStorage": [{"name": "k", "value": "v"}]}
]
_write_storage_state(storage_path, {"cookies": cookies, "origins": origins})
write_account_metadata(storage_path, authuser=1, email="alice@example.com")
payload = _read_storage_state(storage_path)
assert payload["cookies"] == cookies
assert payload["origins"] == origins
def _capture_account_lock_path(monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]:
"""Record the ``storage_state.json`` lock path account.py passes to ``FileLock``.
The captured contextmanager is a no-op so the surrounding read-modify-write
still completes against the real file. ``write_account_metadata`` also calls
``_drop_legacy_account_key``, which locks the sibling ``context.json.lock``;
that path is filtered out so it can't clobber the value we assert on. Paths
are canonicalized (``.expanduser().resolve()``) because they back
cross-process locking, where distinct spellings of the same file must
compare equal.
"""
import contextlib
from notebooklm._auth import account as account_mod
seen: dict[str, Path] = {}
@contextlib.contextmanager
def _fake_filelock(path: str, *args: Any, **kwargs: Any): # type: ignore[no-untyped-def]
resolved = Path(path).expanduser().resolve()
if "storage_state.json" in resolved.name:
seen["path"] = resolved
yield
monkeypatch.setattr(account_mod, "FileLock", _fake_filelock)
return seen
def _capture_storage_lock_path(monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]:
"""Record the lock path storage.py passes to ``_file_lock_exclusive``.
Paths are canonicalized to match ``_capture_account_lock_path`` so the two
captures compare equal even if their spellings differ.
"""
import contextlib
from notebooklm._auth import storage as storage_mod
seen: dict[str, Path] = {}
@contextlib.contextmanager
def _fake_exclusive(lock_path: Path): # type: ignore[no-untyped-def]
seen["path"] = Path(lock_path).expanduser().resolve()
yield
monkeypatch.setattr(storage_mod, "_file_lock_exclusive", _fake_exclusive)
return seen
def test_storage_state_mutators_share_one_lock_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""All ``storage_state.json`` mutators must serialize on the SAME flock file.
Tier-0 data-loss regression: ``save_cookies_to_storage`` (storage.py) used
the dotted ``.storage_state.json.lock`` sibling while ``account.py``'s
metadata writers used the non-dotted ``storage_state.json.lock`` — a
DIFFERENT file. Cookie-save and account-metadata writes therefore locked on
distinct files and could lose updates under concurrency. Assert both paths
derive an identical lock filename for the same ``storage_state.json``.
"""
import httpx
from notebooklm._auth.storage import save_cookies_to_storage
storage_path = tmp_path / "storage_state.json"
_write_storage_state(storage_path, {"cookies": [], "origins": []})
# account.py: write_account_metadata
account_seen = _capture_account_lock_path(monkeypatch)
write_account_metadata(storage_path, authuser=1, email="alice@example.com")
account_write_lock = account_seen["path"]
# account.py: _clear_in_band_account (via clear_account_metadata)
account_seen.clear()
clear_account_metadata(storage_path)
account_clear_lock = account_seen["path"]
# storage.py: save_cookies_to_storage (the canonical cookie-save writer)
storage_seen = _capture_storage_lock_path(monkeypatch)
save_cookies_to_storage(
httpx.Cookies(),
path=storage_path,
original_snapshot={},
)
storage_cookie_lock = storage_seen["path"]
# Canonical name is the dotted, hidden sibling (storage.py contract).
# Resolve to match the canonicalized captures above.
expected = storage_path.with_name(f".{storage_path.name}.lock").expanduser().resolve()
assert storage_cookie_lock == expected
assert account_write_lock == expected
assert account_clear_lock == expected
# And, transitively, all three agree on the exact same file on disk.
assert account_write_lock == account_clear_lock == storage_cookie_lock