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
401 lines
18 KiB
Python
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
|