Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

218 lines
8.3 KiB
Python

"""Tests for ``notebooklm use`` fail-closed verification (PR).
Before PR, ``notebooklm use <id>`` persisted the supplied notebook ID to
``context.json`` *even when the existence check failed* — either because the
RPC errored, or because the server returned a degenerate "empty notebook"
payload for an unknown ID. The result was poisoned saved state that broke
downstream commands until the user manually cleared the context.
This module pins the post-fix contract:
* Successful ``client.notebooks.get`` → context is persisted, exit 0.
* ``NotebookNotFoundError`` from ``client.notebooks.get`` → exit 1, context
file is NOT created.
* ``--force`` flag → context is persisted regardless of verification.
Each test mocks the entire client surface; nothing here hits the network.
"""
from __future__ import annotations
import json
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from click.testing import CliRunner
import notebooklm.auth as auth_module
import notebooklm.cli.context as context_module
import notebooklm.cli.helpers as helpers_module
import notebooklm.cli.resolve as resolve_module
import notebooklm.cli.services.session_context as session_context_module
import notebooklm.cli.session_cmd as session_cmd_module
from notebooklm.exceptions import NotebookNotFoundError, RPCError
from notebooklm.notebooklm_cli import cli
from notebooklm.types import Notebook
from .conftest import create_mock_client, inject_client
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def mock_auth():
"""Patch storage auth so the `use` command sees real-looking cookies."""
with patch.object(helpers_module, "load_auth_from_storage") as mock:
mock.return_value = {
"SID": "test",
"__Secure-1PSIDTS": "test_1psidts",
"HSID": "test",
"SSID": "test",
"APISID": "test",
"SAPISID": "test",
}
yield mock
@pytest.fixture
def mock_context_file(tmp_path):
"""Provide a temporary context path; every consumer call site is patched.
The ``notebooklm.cli.session_cmd.get_context_path`` re-export was retired
in #1367; ``read_status`` now resolves the symbol on its real consumer
module ``services.session_context`` (matching the canonical
``mock_context_file`` fixture in ``conftest.py``).
"""
context_file = tmp_path / "context.json"
with (
patch.object(helpers_module, "get_context_path", return_value=context_file),
patch.object(context_module, "get_context_path", return_value=context_file),
patch.object(resolve_module, "get_context_path", return_value=context_file),
patch.object(
session_context_module,
"get_context_path",
return_value=context_file,
),
):
yield context_file
def _make_notebook(notebook_id: str = "nb_real", title: str = "Real Notebook") -> Notebook:
return Notebook(
id=notebook_id,
title=title,
created_at=datetime(2026, 1, 15),
is_owner=True,
)
class TestNotebookNotFoundIsRPCError:
"""``NotebookNotFoundError`` must still be catchable as ``RPCError``."""
def test_inherits_from_rpc_error(self):
# ``except RPCError`` at higher layers must still match — this is the
# whole point of widening the base class in the fail-closed fix.
assert issubclass(NotebookNotFoundError, RPCError)
def test_carries_notebook_id(self):
err = NotebookNotFoundError("nb_typo")
assert err.notebook_id == "nb_typo"
assert "nb_typo" in str(err)
def test_accepts_method_id(self):
# The CLI/_notebooks.get site forwards method_id for diagnostics.
err = NotebookNotFoundError("nb_x", method_id="rwIQyf")
assert err.method_id == "rwIQyf"
class TestUseFailsClosedBadId:
"""Bad notebook ID → no context write, exit 1."""
def test_notebook_not_found_does_not_persist(self, runner, mock_auth, mock_context_file):
mock_client = create_mock_client()
mock_client.notebooks.get = AsyncMock(
side_effect=NotebookNotFoundError("nb_missing", method_id="rwIQyf"),
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
with patch.object(
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
) as mock_resolve:
mock_resolve.return_value = "nb_missing"
result = runner.invoke(cli, ["use", "nb_missing"], obj=inject_client(mock_client))
# Exit non-zero, never touched context.json.
assert result.exit_code == 1
assert not mock_context_file.exists()
# Surface a clear "not found" message so the user can self-correct.
assert "nb_missing" in result.output
assert "not found" in result.output.lower() or "force" in result.output.lower()
def test_generic_rpc_error_does_not_persist(self, runner, mock_auth, mock_context_file):
"""Network / RPC errors also fail closed — we can't confirm existence."""
mock_client = create_mock_client()
mock_client.notebooks.get = AsyncMock(
side_effect=RPCError("server hung up", method_id="rwIQyf"),
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
with patch.object(
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
) as mock_resolve:
mock_resolve.return_value = "nb_rpc_fail"
result = runner.invoke(cli, ["use", "nb_rpc_fail"], obj=inject_client(mock_client))
assert result.exit_code == 1
assert not mock_context_file.exists()
class TestUseFailsClosedGoodId:
"""Good notebook ID → context persisted, exit 0."""
def test_good_id_persists_context(self, runner, mock_auth, mock_context_file):
mock_client = create_mock_client()
mock_client.notebooks.get = AsyncMock(
return_value=_make_notebook("nb_real", "Real Notebook"),
)
with patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ("csrf", "session")
with patch.object(
session_cmd_module, "resolve_notebook_id", new_callable=AsyncMock
) as mock_resolve:
mock_resolve.return_value = "nb_real"
result = runner.invoke(cli, ["use", "nb_real"], obj=inject_client(mock_client))
assert result.exit_code == 0
assert mock_context_file.exists()
data = json.loads(mock_context_file.read_text())
assert data["notebook_id"] == "nb_real"
assert data["title"] == "Real Notebook"
class TestUseForceFlag:
"""``--force`` bypasses the existence check entirely."""
def test_force_with_bad_id_still_persists(self, runner, mock_context_file):
"""No client mocking needed — --force never calls the network."""
result = runner.invoke(cli, ["use", "--force", "nb_offline"])
assert result.exit_code == 0
assert mock_context_file.exists()
data = json.loads(mock_context_file.read_text())
assert data["notebook_id"] == "nb_offline"
# Banner clarifies the ID was not verified, so the user isn't misled.
assert "not verified" in result.output.lower() or "force" in result.output.lower()
def test_force_does_not_call_get(self, runner, mock_auth, mock_context_file):
"""--force is offline-safe: even a guaranteed-raise ``get`` is bypassed."""
mock_client = create_mock_client()
# If --force *did* call get, this mock would surface the error.
mock_client.notebooks.get = AsyncMock(
side_effect=NotebookNotFoundError("should-not-be-called"),
)
result = runner.invoke(
cli, ["use", "--force", "nb_force_id"], obj=inject_client(mock_client)
)
assert result.exit_code == 0
assert mock_context_file.exists()
# The get-mock side_effect would have surfaced if it had been invoked.
mock_client.notebooks.get.assert_not_called()