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
376 lines
16 KiB
Python
376 lines
16 KiB
Python
"""Unit tests for the MCP structured-error projection.
|
|
|
|
``mcp/_errors.py`` translates a :class:`~notebooklm.exceptions.NotebookLMError`
|
|
into a FastMCP :class:`~fastmcp.exceptions.ToolError` carrying a structured
|
|
payload ``{code, message, retriable, hint?}``. The ``code``/``retriable``/
|
|
``hint`` are derived from ``_app.errors.classify`` via a
|
|
``category -> (code, hint)`` table; ``message`` is redaction-capped but the
|
|
``code`` + ``retriable`` are always preserved.
|
|
|
|
These tests pin, for an exemplar of EVERY ``ErrorCategory``, the projected
|
|
code + retriable + hint. The exemplar list mirrors
|
|
``tests/_guardrails/test_classify_error_handler_consistency.py`` so the two
|
|
ladders stay aligned.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
# Skip cleanly when the `mcp` extra (fastmcp) is absent; see conftest.py.
|
|
pytest.importorskip("fastmcp")
|
|
|
|
from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip guard
|
|
|
|
from notebooklm import exceptions as exc # noqa: E402 - after importorskip guard
|
|
from notebooklm._app import SourceMutationError # noqa: E402 - after importorskip guard
|
|
from notebooklm._app.errors import ( # noqa: E402 - after importorskip guard
|
|
ErrorCategory,
|
|
classify,
|
|
)
|
|
from notebooklm.mcp._errors import ( # noqa: E402 - after importorskip guard
|
|
CATEGORY_TABLE,
|
|
ERROR_CODES,
|
|
mcp_errors,
|
|
redact,
|
|
to_tool_error,
|
|
tool_error_payload,
|
|
)
|
|
|
|
# One exemplar per category — same exemplars the CLI consistency gate uses.
|
|
_EXEMPLARS: list[tuple[ErrorCategory, BaseException]] = [
|
|
(ErrorCategory.NOT_FOUND, exc.SourceNotFoundError("src_456")),
|
|
(ErrorCategory.AUTH, exc.AuthError("auth failed")),
|
|
(ErrorCategory.RATE_LIMITED, exc.RateLimitError("slow down", retry_after=5)),
|
|
(ErrorCategory.VALIDATION, exc.ValidationError("bad input")),
|
|
(ErrorCategory.CONFIG, exc.ConfigurationError("missing config")),
|
|
(ErrorCategory.NETWORK, exc.NetworkError("connection refused")),
|
|
(ErrorCategory.NOTEBOOK_LIMIT, exc.NotebookLimitError(499, limit=500)),
|
|
(ErrorCategory.ARTIFACT_TIMEOUT, exc.ArtifactTimeoutError("nb-1", "task-1", 30.0)),
|
|
(ErrorCategory.TIMEOUT, exc.WaitTimeoutError("generic wait timed out")),
|
|
(ErrorCategory.SERVER, exc.ServerError("upstream 503")),
|
|
(ErrorCategory.RPC, exc.RPCError("decode failed", method_id="abc123")),
|
|
(ErrorCategory.SOURCE_MUTATION, SourceMutationError("ambiguous", "AMBIGUOUS_ID")),
|
|
(ErrorCategory.LIBRARY, exc.NotebookLMError("some library error")),
|
|
(ErrorCategory.UNEXPECTED, RuntimeError("boom")),
|
|
]
|
|
|
|
# The MCP code each neutral category projects onto, and whether it is retriable.
|
|
# retriable mirrors ``_app.errors`` (rate-limit / server / timeout / network),
|
|
# never re-derived here.
|
|
# NOTE: this map is duplicated INTENTIONALLY from ``CATEGORY_TABLE`` (and from
|
|
# ``test_mcp_classify_consistency.py``) as an INDEPENDENT ORACLE — do NOT "DRY" it
|
|
# into a shared import. Hand-writing the expected projection is what lets the test
|
|
# catch a wrong edit to the production table; importing the table would make it
|
|
# tautological.
|
|
_CATEGORY_TO_MCP_CODE: dict[ErrorCategory, str] = {
|
|
ErrorCategory.NOT_FOUND: "NOT_FOUND",
|
|
ErrorCategory.AUTH: "AUTH",
|
|
ErrorCategory.RATE_LIMITED: "RATE_LIMITED",
|
|
ErrorCategory.VALIDATION: "VALIDATION",
|
|
ErrorCategory.CONFIG: "CONFIG",
|
|
ErrorCategory.NETWORK: "NETWORK",
|
|
ErrorCategory.NOTEBOOK_LIMIT: "NOTEBOOK_LIMIT",
|
|
ErrorCategory.ARTIFACT_TIMEOUT: "ARTIFACT_TIMEOUT",
|
|
ErrorCategory.TIMEOUT: "TIMEOUT",
|
|
ErrorCategory.SERVER: "SERVER",
|
|
ErrorCategory.RPC: "RPC",
|
|
ErrorCategory.SOURCE_MUTATION: "SOURCE_MUTATION",
|
|
ErrorCategory.LIBRARY: "ERROR",
|
|
ErrorCategory.UNEXPECTED: "UNEXPECTED",
|
|
}
|
|
|
|
|
|
def test_table_covers_every_category() -> None:
|
|
"""A new ``ErrorCategory`` with no table entry fails here."""
|
|
assert set(CATEGORY_TABLE) == set(ErrorCategory)
|
|
|
|
|
|
def test_error_codes_is_the_table_code_set() -> None:
|
|
"""``ERROR_CODES`` is the pinned set of codes the table can emit."""
|
|
assert frozenset(code for code, _ in CATEGORY_TABLE.values()) == ERROR_CODES
|
|
|
|
|
|
def test_one_exemplar_per_category() -> None:
|
|
"""Exactly one exemplar per category — the parametrization is exhaustive."""
|
|
assert {category for category, _ in _EXEMPLARS} == set(ErrorCategory)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("category", "exception"),
|
|
_EXEMPLARS,
|
|
ids=[category.name for category, _ in _EXEMPLARS],
|
|
)
|
|
def test_payload_projects_code_retriable_hint(
|
|
category: ErrorCategory, exception: BaseException
|
|
) -> None:
|
|
payload = tool_error_payload(exception)
|
|
expected_code, expected_hint = CATEGORY_TABLE[category]
|
|
classified = classify(exception)
|
|
|
|
assert payload["code"] == expected_code == _CATEGORY_TO_MCP_CODE[category]
|
|
assert payload["retriable"] is classified.retriable
|
|
assert isinstance(payload["message"], str) and payload["message"]
|
|
if expected_hint is None:
|
|
assert "hint" not in payload
|
|
else:
|
|
assert payload["hint"] == expected_hint
|
|
|
|
|
|
def test_retriable_categories_are_marked_retriable() -> None:
|
|
"""The transient categories project retriable=True; deterministic ones False."""
|
|
retriable = {
|
|
ErrorCategory.RATE_LIMITED,
|
|
ErrorCategory.SERVER,
|
|
ErrorCategory.TIMEOUT,
|
|
ErrorCategory.ARTIFACT_TIMEOUT,
|
|
ErrorCategory.NETWORK,
|
|
}
|
|
for category, exception in _EXEMPLARS:
|
|
assert tool_error_payload(exception)["retriable"] is (category in retriable)
|
|
|
|
|
|
def test_message_is_redaction_capped_but_code_preserved() -> None:
|
|
"""A very long message is capped; code + retriable still present and correct."""
|
|
long = exc.ValidationError("x" * 2000)
|
|
payload = tool_error_payload(long)
|
|
assert payload["code"] == "VALIDATION"
|
|
assert payload["retriable"] is False
|
|
assert len(payload["message"]) <= 320 # cap + ellipsis slack
|
|
|
|
|
|
def test_to_tool_error_returns_tool_error_with_payload() -> None:
|
|
err = to_tool_error(exc.RateLimitError("slow", retry_after=3))
|
|
assert isinstance(err, ToolError)
|
|
# FastMCP ToolError surfaces the structured payload; the code must be readable.
|
|
assert "RATE_LIMITED" in str(err)
|
|
|
|
|
|
def test_not_found_candidates_surface_in_payload_and_did_you_mean_hint() -> None:
|
|
"""Near-miss candidates (issue #1787) appear structurally and swap the hint."""
|
|
err = exc.NotebookNotFoundError("Scientific")
|
|
err.candidates = [{"id": "37fe5c1d", "title": "Scientific PDF Parsing — Landscape"}]
|
|
payload = tool_error_payload(err)
|
|
|
|
assert payload["code"] == "NOT_FOUND"
|
|
assert payload["candidates"] == [
|
|
{"id": "37fe5c1d", "title": "Scientific PDF Parsing — Landscape"}
|
|
]
|
|
# The generic NOT_FOUND hint is replaced by a "Did you mean …" hint that
|
|
# names the title AND id inline (so a flat-string MCP client still sees both).
|
|
assert payload["hint"].startswith("Did you mean:")
|
|
assert "Scientific PDF Parsing — Landscape" in payload["hint"]
|
|
assert "37fe5c1d" in payload["hint"]
|
|
|
|
|
|
def test_candidate_id_reaches_the_flattened_toolerror_wire() -> None:
|
|
"""to_tool_error drops the structured list, so the id must ride the hint string.
|
|
|
|
Regression for the codex P2: an MCP client only sees the flat ToolError
|
|
message, and must be able to retry by id without another list call.
|
|
"""
|
|
err = exc.SourceNotFoundError("Quarterly - Revenue")
|
|
err.candidates = [{"id": "src-abc123-def456", "title": "Quarterly — Revenue Deck"}]
|
|
wire = str(to_tool_error(err))
|
|
assert "Did you mean:" in wire
|
|
assert "src-abc123-def456" in wire
|
|
assert "Quarterly — Revenue Deck" in wire
|
|
|
|
|
|
def test_not_found_without_candidates_keeps_generic_hint() -> None:
|
|
"""A miss with no near match carries no candidates and the generic hint."""
|
|
payload = tool_error_payload(exc.NotebookNotFoundError("Nonexistent"))
|
|
assert "candidates" not in payload
|
|
assert payload["hint"] == CATEGORY_TABLE[ErrorCategory.NOT_FOUND][1]
|
|
|
|
|
|
def test_validation_error_with_candidates_is_enriched() -> None:
|
|
"""A label near-miss (VALIDATION-coded, candidates on the attr) still enriches.
|
|
|
|
``source_list(label=…)`` resolves labels by name and raises the VALIDATION-
|
|
coded ``LabelResolutionError``; its ``.candidates`` must reach the wire with a
|
|
"Did you mean" hint even though the code is not NOT_FOUND.
|
|
"""
|
|
err = exc.ValidationError("No label found matching 'Q3 - Papers'")
|
|
err.candidates = [{"id": "lbl1", "title": "Q3 — Papers"}] # type: ignore[attr-defined]
|
|
payload = tool_error_payload(err)
|
|
assert payload["code"] == "VALIDATION"
|
|
assert payload["candidates"] == [{"id": "lbl1", "title": "Q3 — Papers"}]
|
|
assert payload["hint"].startswith("Did you mean:")
|
|
|
|
|
|
def test_mcp_errors_translates_notebooklm_error() -> None:
|
|
with pytest.raises(ToolError) as caught, mcp_errors(): # noqa: PT012
|
|
raise exc.NotFoundError("missing")
|
|
assert "NOT_FOUND" in str(caught.value)
|
|
|
|
|
|
def test_mcp_errors_wraps_unexpected_exception() -> None:
|
|
"""A plain ``RuntimeError`` is wrapped into a ToolError with code UNEXPECTED.
|
|
|
|
Without this the advertised ``UNEXPECTED`` projection is never produced — a
|
|
non-library exception would escape ``mcp_errors()`` unwrapped.
|
|
"""
|
|
with pytest.raises(ToolError) as caught, mcp_errors(): # noqa: PT012
|
|
raise RuntimeError("boom")
|
|
assert "UNEXPECTED" in str(caught.value)
|
|
|
|
|
|
def test_mcp_errors_propagates_base_exceptions() -> None:
|
|
"""``CancelledError`` (a ``BaseException``) propagates uncaught — never wrapped.
|
|
|
|
``except Exception`` deliberately does not catch ``asyncio.CancelledError`` /
|
|
``KeyboardInterrupt`` / ``SystemExit`` so cancellation/shutdown is never
|
|
swallowed into a ToolError.
|
|
"""
|
|
import asyncio
|
|
|
|
with pytest.raises(asyncio.CancelledError), mcp_errors():
|
|
raise asyncio.CancelledError
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# redact() — secret / path pattern redaction (#1682)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_redact_strips_bearer_token() -> None:
|
|
"""An ``Authorization: Bearer`` header value is masked (via scrub_secrets).
|
|
|
|
Uses the header form (which redacts ANY value) rather than a bare token, whose
|
|
shape regex intentionally ignores short non-realistic tokens.
|
|
"""
|
|
out = redact("RPC failed: Authorization: Bearer ya29.s3cr3t-Token_VALUE-abc123")
|
|
assert "ya29.s3cr3t-Token_VALUE-abc123" not in out
|
|
assert "Bearer" in out # the header name survives as a shape hint
|
|
|
|
|
|
def test_redact_strips_session_cookie_values() -> None:
|
|
"""A ``Cookie:`` header and a ``__Secure-*`` cookie value are masked."""
|
|
out = redact("boom Cookie: SID=AAAA1111secret; HSID=BBBB2222secret")
|
|
assert "AAAA1111secret" not in out
|
|
assert "BBBB2222secret" not in out
|
|
out2 = redact("__Secure-1PSIDTS=zzzzSECRETvalue")
|
|
assert "zzzzSECRETvalue" not in out2
|
|
|
|
|
|
def test_redact_strips_signed_files_url_token_bare_and_in_url() -> None:
|
|
"""The ``/files/(dl|ul)/<token>`` side-channel token is redacted."""
|
|
bare = redact("link is /files/dl/eyJvcCI6ImRsIn0.ZmFrZW1hYw and expired")
|
|
assert "/files/dl/***" in bare
|
|
assert "eyJvcCI6ImRsIn0" not in bare
|
|
in_url = redact("open https://files.test/files/ul/eyJvcCI6InVsIn0.bWFjbWFj?x=1 now")
|
|
assert "/files/ul/***" in in_url
|
|
assert "eyJvcCI6InVsIn0" not in in_url
|
|
# A malformed multi-dot token leaves no tail.
|
|
multidot = redact("/files/dl/a.b.c")
|
|
assert multidot == "/files/dl/***"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("message", "leaked", "expected_fragment"),
|
|
[
|
|
(
|
|
"open /home/alice/.notebooklm/default/storage_state.json failed",
|
|
"alice",
|
|
"/home/***/.notebooklm/default/storage_state.json",
|
|
),
|
|
# macOS account name with a space (the round-1 codex concern).
|
|
(
|
|
"read /Users/Alice Smith/Library/data.json",
|
|
"Smith",
|
|
"/Users/***/Library/data.json",
|
|
),
|
|
# Windows account name with a space.
|
|
(r"open C:\Users\Bob Smith\app\state failed", "Bob", r"C:\Users\***\app\state"),
|
|
],
|
|
)
|
|
def test_redact_masks_home_directory_usernames(message, leaked, expected_fragment) -> None:
|
|
out = redact(message)
|
|
assert leaked not in out
|
|
assert expected_fragment in out
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
# A single-word terminal username IS redacted, but surrounding prose /
|
|
# punctuation must survive verbatim.
|
|
"/home/alice: permission denied",
|
|
# Multi-path prose must NOT be eaten reaching for a later separator.
|
|
"Could not find /home/alice or /home/bob/config",
|
|
"/Users/Alice Smith: permission denied for /Users/Bob/x",
|
|
],
|
|
)
|
|
def test_redact_never_eats_prose_between_paths(message) -> None:
|
|
out = redact(message)
|
|
# The prose words between path-ish fragments survive verbatim.
|
|
for word in ("permission", "denied", "Could", "find", "or", "for"):
|
|
if word in message:
|
|
assert word in out
|
|
|
|
|
|
def test_redact_masks_terminal_and_punctuation_bounded_usernames() -> None:
|
|
"""A single-word username is redacted even without a trailing separator.
|
|
|
|
It cannot eat prose (the token stops at whitespace/punctuation), so requiring a
|
|
trailing ``/`` would needlessly leak terminal usernames (gemini #1695). Internal
|
|
dots/hyphens are part of the name; a trailing ``.``/``)`` of prose is preserved.
|
|
"""
|
|
assert redact("dir is /home/alice") == "dir is /home/***"
|
|
assert redact("/home/alice: permission denied") == "/home/***: permission denied"
|
|
assert redact("Could not find /home/alice.") == "Could not find /home/***."
|
|
assert redact("see (/home/alice)") == "see (/home/***)"
|
|
assert redact("/home/john.doe/x") == "/home/***/x" # internal dot kept inside name
|
|
assert redact("/home/web-admin") == "/home/***" # internal hyphen kept inside name
|
|
|
|
|
|
def test_redact_exact_output_for_multi_path_prose() -> None:
|
|
"""Exact-output guards: every path component is masked, all prose survives."""
|
|
assert (
|
|
redact("Could not find /home/alice or /home/bob/config")
|
|
== "Could not find /home/*** or /home/***/config"
|
|
)
|
|
# A two-word username NOT followed by a separator masks only its first word
|
|
# (documented fail-safe bound); the prose is still fully preserved.
|
|
assert (
|
|
redact("/Users/Alice Smith: permission denied for /Users/Bob/x")
|
|
== "/Users/*** Smith: permission denied for /Users/***/x"
|
|
)
|
|
|
|
|
|
def test_redact_home_pattern_capture_group_is_only_the_prefix() -> None:
|
|
"""The ``\\1`` capture is just the ``/home/`` prefix — the username is the part
|
|
dropped, not coincidentally preserved (gemini #1695 testing guidance)."""
|
|
from notebooklm._redact import _EXTRA_PATTERNS
|
|
|
|
home_pattern = _EXTRA_PATTERNS[1][0]
|
|
m = home_pattern.search("Could not find /home/alice")
|
|
assert m is not None
|
|
assert m.group(1) == "/home/"
|
|
assert m.group(0) == "/home/alice" # the whole match (prefix + username) is replaced
|
|
|
|
|
|
def test_unexpected_exception_message_is_generic_not_raw_text() -> None:
|
|
"""A non-library bug's raw ``str(exc)`` is never echoed (redact is a denylist).
|
|
|
|
``redact`` only masks KNOWN credential/path shapes, so an unexpected exception
|
|
could otherwise leak arbitrary text (env detail, non-home paths). The UNEXPECTED
|
|
category therefore returns a fixed generic message while preserving code +
|
|
retriable (mirrors the REST server policy).
|
|
"""
|
|
leaky = RuntimeError("kaboom: opened /var/lib/notebooklm/secret.cfg key=hunter2")
|
|
payload = tool_error_payload(leaky)
|
|
assert payload["code"] == "UNEXPECTED"
|
|
assert payload["retriable"] is False
|
|
assert "secret.cfg" not in payload["message"]
|
|
assert "hunter2" not in payload["message"]
|
|
assert "/var/lib/notebooklm" not in payload["message"]
|
|
|
|
|
|
def test_redact_runs_before_truncation_so_secret_not_half_cut() -> None:
|
|
"""A secret sitting near the length cap must be fully redacted, never half-shown."""
|
|
filler = "x" * 290
|
|
secret = "AAAA1111-this-is-a-secret-cookie-value"
|
|
out = redact(f"{filler} Cookie: SID={secret}")
|
|
assert secret not in out
|
|
assert "1111-this-is-a-secret" not in out # no partial tail survived the cap
|