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

401 lines
18 KiB
Python

"""Coverage-focused tests for ``notebooklm._auth.refresh`` branches.
Targets the refresh-cmd driver branches that the concern-aligned
``test_auth_refresh.py`` / ``test_refresh_cmd_shlex.py`` /
``test_refresh_cmd_redaction.py`` suites do not exercise: the
``_should_try_refresh`` attempted-flag guard, ``_run_refresh_cmd``
missing-command and subprocess-failure (timeout / OSError) raises, the
shell-mode and empty-target basename extraction in the non-zero-exit raise,
the ``_settle`` future-cancel callback, and the post-refresh retry
``route_kwargs`` (``account_email`` / ``force_authuser_query``) plumbing.
New file per ADR-0007: patches the owning ``_auth.refresh`` module at the
bare-name call site rather than editing the existing concern-aligned files.
"""
from __future__ import annotations
import asyncio
import ctypes
import subprocess
from pathlib import Path
from typing import Any
import httpx
import pytest
from notebooklm._auth import refresh as _auth_refresh
@pytest.fixture(autouse=True)
def _clear_refresh_env(monkeypatch):
"""Each test starts with no inherited refresh-cmd env vars / flags."""
monkeypatch.delenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, raising=False)
monkeypatch.delenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_USE_SHELL_ENV, raising=False)
monkeypatch.delenv(_auth_refresh._REFRESH_ATTEMPTED_ENV, raising=False)
class TestShouldTryRefresh:
"""``_should_try_refresh`` guard branches ."""
def test_false_when_context_flag_set(self, monkeypatch):
# ContextVar attempted flag set → no second refresh attempt .
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, "echo hi")
token = _auth_refresh._REFRESH_ATTEMPTED_CONTEXT.set(True)
try:
assert _auth_refresh._should_try_refresh(ValueError("authentication expired")) is False
finally:
_auth_refresh._REFRESH_ATTEMPTED_CONTEXT.reset(token)
def test_false_when_env_attempted_flag_set(self, monkeypatch):
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, "echo hi")
monkeypatch.setenv(_auth_refresh._REFRESH_ATTEMPTED_ENV, "1")
assert _auth_refresh._should_try_refresh(ValueError("authentication expired")) is False
def test_false_when_cmd_env_unset(self):
assert _auth_refresh._should_try_refresh(ValueError("authentication expired")) is False
def test_true_for_auth_signal_with_cmd_set(self, monkeypatch):
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, "echo hi")
assert _auth_refresh._should_try_refresh(ValueError("Redirected to login")) is True
def test_false_for_non_auth_error(self, monkeypatch):
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, "echo hi")
assert _auth_refresh._should_try_refresh(ValueError("network down")) is False
class TestRunRefreshCmdMissing:
"""``_run_refresh_cmd`` raises when the env var is unset ."""
@pytest.mark.asyncio
async def test_missing_cmd_raises_runtime_error(self):
with pytest.raises(RuntimeError, match="is not set; cannot refresh cookies"):
await _auth_refresh._run_refresh_cmd()
class TestRunRefreshCmdSubprocessFailure:
"""``subprocess.run`` raising → RuntimeError ."""
def _stub_storage(self, monkeypatch, tmp_path):
storage = tmp_path / "storage_state.json"
storage.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_auth_refresh, "get_storage_path", lambda profile=None: storage)
return storage
@pytest.mark.asyncio
async def test_timeout_expired_becomes_runtime_error(self, monkeypatch, tmp_path):
self._stub_storage(monkeypatch, tmp_path)
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, "echo hi")
def _raise_timeout(*args, **kwargs):
raise subprocess.TimeoutExpired(cmd="echo", timeout=60)
monkeypatch.setattr(subprocess, "run", _raise_timeout)
with pytest.raises(RuntimeError, match="failed to execute"):
await _auth_refresh._run_refresh_cmd()
@pytest.mark.asyncio
async def test_oserror_becomes_runtime_error(self, monkeypatch, tmp_path):
self._stub_storage(monkeypatch, tmp_path)
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, "echo hi")
def _raise_oserror(*args, **kwargs):
raise OSError("exec format error")
monkeypatch.setattr(subprocess, "run", _raise_oserror)
with pytest.raises(RuntimeError, match="failed to execute"):
await _auth_refresh._run_refresh_cmd()
class TestRunRefreshCmdNonZeroExitBasename:
"""Non-zero exit basename extraction for shell / empty targets (390-393)."""
def _stub_storage(self, monkeypatch, tmp_path):
storage = tmp_path / "storage_state.json"
storage.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_auth_refresh, "get_storage_path", lambda profile=None: storage)
def _stub_nonzero_run(self, monkeypatch):
class _Result:
returncode = 7
stdout = "secret-out"
stderr = "secret-err"
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _Result())
@pytest.mark.asyncio
async def test_shell_mode_string_target_basename(self, monkeypatch, tmp_path):
# Shell-mode keeps ``run_target`` as a raw string; basename comes from
# its first whitespace-split token .
self._stub_storage(monkeypatch, tmp_path)
monkeypatch.setenv(
_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV,
"/opt/secrets/do-refresh.sh --token=hunter2 | tee log",
)
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_USE_SHELL_ENV, "1")
self._stub_nonzero_run(monkeypatch)
with pytest.raises(RuntimeError) as exc_info:
await _auth_refresh._run_refresh_cmd()
message = exc_info.value.args[0]
assert "exited 7" in message
assert "do-refresh.sh" in message
# Neither the directory nor the token leak into the user-facing message.
assert "/opt/secrets" not in message
assert "hunter2" not in message
@pytest.mark.asyncio
async def test_shell_mode_whitespace_only_target_uses_shell_literal(
self, monkeypatch, tmp_path
):
# Whitespace-only command string in shell-mode → ``run_target.strip()``
# is empty so the basename falls back to the literal ``"shell"`` (393).
self._stub_storage(monkeypatch, tmp_path)
# The env-not-set guard treats "" as missing; spaces bypass it while
# still stripping to empty in the basename branch.
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, " ")
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_USE_SHELL_ENV, "1")
self._stub_nonzero_run(monkeypatch)
with pytest.raises(RuntimeError) as exc_info:
await _auth_refresh._run_refresh_cmd()
message = exc_info.value.args[0]
assert "exited 7" in message
assert "executable: shell" in message
class TestSplitRefreshCmdWindowsBranch:
"""Cover the Windows ``CommandLineToArgvW`` branch on non-Windows hosts.
The branch is gated on ``os.name == "nt"`` and calls into
``ctypes.windll`` (which does not exist off-Windows). We fake ``os.name``
and inject a stand-in ``ctypes.windll`` so the parsing logic — including
the NULL-pointer empty-input guard and the empty-entry filter — is
exercised on Linux CI.
"""
@staticmethod
def _install_fake_windll(monkeypatch, *, parsed: list[str] | None, null: bool = False):
freed = {"count": 0}
def _argv_for(_cmd, argc_ref):
if null:
argc_ref._obj.value = 0
return None # NULL pointer → empty-input guard (282->286).
argc_ref._obj.value = len(parsed or [])
# Return a simple index-addressable container; the source reads
# ``argv_ptr[i]`` for ``i in range(argc.value)``.
return list(parsed or [])
class _Shell32:
CommandLineToArgvW = staticmethod(_argv_for)
class _Kernel32:
@staticmethod
def LocalFree(_ptr):
freed["count"] += 1
return None
class _WinDLL:
shell32 = _Shell32()
kernel32 = _Kernel32()
monkeypatch.setattr(_auth_refresh.os, "name", "nt")
monkeypatch.setattr(ctypes, "windll", _WinDLL(), raising=False)
# ``ctypes.byref`` wraps the ``c_int`` so the fake can mutate ``.value``.
real_byref = ctypes.byref
class _Ref:
def __init__(self, obj):
self._obj = obj
monkeypatch.setattr(ctypes, "byref", lambda obj: _Ref(obj))
# ``cast`` is only used to free the buffer; make it a passthrough so it
# does not choke on our list stand-in.
monkeypatch.setattr(ctypes, "cast", lambda ptr, typ: ptr)
return freed, real_byref
def test_windows_parses_quoted_tokens_and_frees(self, monkeypatch):
freed, _ = self._install_fake_windll(
monkeypatch, parsed=[r"C:\Program Files\python.exe", "script.py"]
)
argv = _auth_refresh._split_refresh_cmd('"C:\\Program Files\\python.exe" script.py')
assert argv == [r"C:\Program Files\python.exe", "script.py"]
# The ``finally`` LocalFree cleanup ran .
assert freed["count"] == 1
def test_windows_filters_empty_entries(self, monkeypatch):
# Whitespace-only input → CommandLineToArgvW returns a single empty
# entry; the comprehension filters it so the caller's empty-argv guard
# trips.
self._install_fake_windll(monkeypatch, parsed=[""])
assert _auth_refresh._split_refresh_cmd(" ") == []
def test_windows_null_pointer_returns_empty(self, monkeypatch):
# NULL pointer for some empty-input edge cases -> early empty list return.
self._install_fake_windll(monkeypatch, parsed=None, null=True)
assert _auth_refresh._split_refresh_cmd("") == []
class TestSettleCallbackCancel:
"""``_settle`` propagates task cancellation to the future ."""
@pytest.mark.asyncio
async def test_cancelled_task_cancels_future(self, monkeypatch):
# Drive ``_coalesced_run_refresh_cmd`` where the underlying
# ``_run_refresh_cmd`` task is cancelled mid-flight. ``_settle`` must
# call ``future.cancel()`` , surfacing CancelledError to the
# awaiter.
started = asyncio.Event()
async def _slow_refresh(storage_path=None, profile=None):
started.set()
await asyncio.sleep(10)
monkeypatch.setattr(_auth_refresh, "_run_refresh_cmd", _slow_refresh)
async def _drive():
await _auth_refresh._coalesced_run_refresh_cmd(
"cancel-key", Path("/tmp/storage_state.json"), None
)
task = asyncio.create_task(_drive())
await started.wait()
# Cancel the leader subprocess task directly so ``_settle`` runs the
# ``t.cancelled()`` → ``future.cancel()`` branch.
inflight = list(_auth_refresh._REFRESH_INFLIGHT_TASKS)
assert inflight, "expected an in-flight refresh task"
for t in inflight:
t.cancel()
with pytest.raises(asyncio.CancelledError):
await task
class TestFetchTokensCancelSettleRace:
"""Caller-cancel + already-settled in-flight future race .
When ``_coalesced_run_refresh_cmd`` raises ``CancelledError`` (caller-side
cancellation) but the underlying subprocess future is already ``done()``
and left in the registry by ``_settle``, ``_fetch_tokens_with_refresh``
inspects the future's terminal state: a cancelled future yields a synthetic
``CancelledError`` (575->578); a failed future yields its exception
(580); either way the loop breaks (581) and the caller propagates
``CancelledError``.
"""
def _common_patches(self, monkeypatch, storage):
async def fake_fetch_tokens_with_jar(cookie_jar, storage_path=None, **route_kwargs):
raise ValueError("Authentication expired. Redirected to login.")
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, "echo hi")
monkeypatch.setattr(_auth_refresh, "get_storage_path", lambda profile=None: storage)
monkeypatch.setattr(_auth_refresh, "_fetch_tokens_with_jar", fake_fetch_tokens_with_jar)
@pytest.mark.asyncio
async def test_done_future_with_exception_propagates_cancel(self, monkeypatch, tmp_path):
storage = tmp_path / "storage_state.json"
storage.write_text("{}", encoding="utf-8")
self._common_patches(monkeypatch, storage)
refresh_key = str(storage.expanduser().resolve())
async def fake_coalesced(key, resolved_storage_path, profile):
# Insert a DONE future carrying an exception into the per-loop
# registry, then raise CancelledError to simulate caller-side
# cancellation arriving after the subprocess settled with failure.
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
fut.set_exception(RuntimeError("subprocess boom"))
registry = _auth_refresh._get_inflight_registry()
with _auth_refresh._REFRESH_STATE_LOCK:
registry[key] = fut
raise asyncio.CancelledError()
monkeypatch.setattr(_auth_refresh, "_coalesced_run_refresh_cmd", fake_coalesced)
with pytest.raises(asyncio.CancelledError):
await _auth_refresh._fetch_tokens_with_refresh(httpx.Cookies(), storage, None)
# Clean up the leftover registry entry so other tests start clean.
registry = _auth_refresh._get_inflight_registry()
with _auth_refresh._REFRESH_STATE_LOCK:
registry.pop(refresh_key, None)
@pytest.mark.asyncio
async def test_done_future_cancelled_propagates_cancel(self, monkeypatch, tmp_path):
storage = tmp_path / "storage_state2.json"
storage.write_text("{}", encoding="utf-8")
self._common_patches(monkeypatch, storage)
refresh_key = str(storage.expanduser().resolve())
async def fake_coalesced(key, resolved_storage_path, profile):
# Insert a DONE *cancelled* future, then raise CancelledError. This
# drives the ``inflight.cancelled()`` branch (575 -> 578).
loop = asyncio.get_running_loop()
fut: asyncio.Future[None] = loop.create_future()
fut.cancel()
# Allow the cancellation to settle the future as cancelled.
try:
await asyncio.sleep(0)
except asyncio.CancelledError:
pass
registry = _auth_refresh._get_inflight_registry()
with _auth_refresh._REFRESH_STATE_LOCK:
registry[key] = fut
raise asyncio.CancelledError()
monkeypatch.setattr(_auth_refresh, "_coalesced_run_refresh_cmd", fake_coalesced)
with pytest.raises(asyncio.CancelledError):
await _auth_refresh._fetch_tokens_with_refresh(httpx.Cookies(), storage, None)
registry = _auth_refresh._get_inflight_registry()
with _auth_refresh._REFRESH_STATE_LOCK:
registry.pop(refresh_key, None)
class TestPostRefreshRetryRouteKwargs:
"""Post-refresh retry forwards account_email / force_authuser_query (637, 639)."""
@pytest.mark.asyncio
async def test_retry_forwards_account_email_and_force_authuser(self, monkeypatch, tmp_path):
storage = tmp_path / "storage_state.json"
storage.write_text("{}", encoding="utf-8")
# First fetch raises an auth-expiry ValueError to trigger refresh; the
# retry fetch records the route kwargs it received.
calls: list[dict[str, Any]] = []
state = {"first": True}
async def fake_fetch_tokens_with_jar(cookie_jar, storage_path=None, **route_kwargs):
if state["first"]:
state["first"] = False
raise ValueError("Authentication expired. Redirected to login.")
calls.append(route_kwargs)
return "csrf_after", "sess_after"
async def fake_coalesced(refresh_key, resolved_storage_path, profile):
return None
def fake_build_jar(path):
return httpx.Cookies()
def fake_replace(target, source):
return None
def fake_snapshot(jar):
return None
monkeypatch.setenv(_auth_refresh.NOTEBOOKLM_REFRESH_CMD_ENV, "echo hi")
monkeypatch.setattr(_auth_refresh, "get_storage_path", lambda profile=None: storage)
monkeypatch.setattr(_auth_refresh, "_fetch_tokens_with_jar", fake_fetch_tokens_with_jar)
monkeypatch.setattr(_auth_refresh, "_coalesced_run_refresh_cmd", fake_coalesced)
monkeypatch.setattr(_auth_refresh, "build_httpx_cookies_from_storage", fake_build_jar)
monkeypatch.setattr(_auth_refresh, "_replace_cookie_jar", fake_replace)
monkeypatch.setattr(_auth_refresh, "snapshot_cookie_jar", fake_snapshot)
csrf, session_id, refreshed, _snap = await _auth_refresh._fetch_tokens_with_refresh(
httpx.Cookies(),
storage,
None,
authuser=0,
account_email="bob@example.com",
force_authuser_query=True,
)
assert refreshed is True
assert csrf == "csrf_after"
assert session_id == "sess_after"
assert calls, "retry fetch was not invoked"
retry_kwargs = calls[0]
assert retry_kwargs["account_email"] == "bob@example.com"
assert retry_kwargs["force_authuser_query"] is True
assert retry_kwargs["authuser"] == 0