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

356 lines
15 KiB
Python

"""Unit tests for the sharing MCP tools.
Drives ``share_*`` through the in-memory FastMCP ``Client`` against the mocked
``NotebookLMClient.sharing``, asserting the serialized ``structured_content``.
Covers the string-label projection, the ``view_level`` read-limitation (surfaced
ONLY when ``share_set_access`` set it, omitted everywhere else), the ``set_access``
fold ordering, the ``set_user`` upsert, the confirm-gated remove flow, and
schema-boundary rejection of out-of-enum inputs.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
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.mcp.tools.sharing import _label # noqa: E402 - after importorskip guard
from notebooklm.rpc.types import ( # noqa: E402 - after importorskip guard
ShareAccess,
SharePermission,
ShareViewLevel,
)
from .conftest import AsyncMock # noqa: E402 - after importorskip guard
NB_ID = "11111111-1111-1111-1111-111111111111"
@dataclass
class FakeSharedUser:
email: str
permission: Any = SharePermission.VIEWER
display_name: str | None = None
avatar_url: str | None = None
@dataclass
class FakeShareStatus:
notebook_id: str = NB_ID
is_public: bool = False
access: Any = ShareAccess.RESTRICTED
view_level: Any = ShareViewLevel.FULL_NOTEBOOK
shared_users: list = field(default_factory=list)
share_url: str | None = None
# ---------------------------------------------------------------------------
# _label helper
# ---------------------------------------------------------------------------
def test_label_maps_enum_and_int() -> None:
assert _label({0: "restricted", 1: "anyone_with_link"}, ShareAccess.ANYONE_WITH_LINK) == (
"anyone_with_link"
)
assert _label({1: "owner", 2: "editor", 3: "viewer"}, SharePermission.EDITOR) == "editor"
def test_label_unknown_value_degrades_to_str() -> None:
"""An unexpected int (e.g. SharePermission._REMOVE=4 or drift) never KeyErrors."""
assert _label({1: "owner", 2: "editor", 3: "viewer"}, SharePermission._REMOVE) == "4"
assert _label({0: "restricted"}, 99) == "99"
# ---------------------------------------------------------------------------
# share_status
# ---------------------------------------------------------------------------
async def test_share_status_labels_enums_and_omits_view_level(mcp_call, mock_client) -> None:
mock_client.sharing.get_status = AsyncMock(
return_value=FakeShareStatus(
is_public=True,
access=ShareAccess.ANYONE_WITH_LINK,
view_level=ShareViewLevel.CHAT_ONLY, # would be a LIE if surfaced from get_status
share_url="https://nb/share",
shared_users=[FakeSharedUser(email="a@b.com", permission=SharePermission.EDITOR)],
)
)
result = await mcp_call("share_status", {"notebook": NB_ID})
sc = result.structured_content
assert sc["is_public"] is True
assert sc["access"] == "anyone_with_link" # string, not int
assert sc["share_url"] == "https://nb/share"
assert sc["shared_users"] == [
{"email": "a@b.com", "permission": "editor", "display_name": None, "avatar_url": None}
]
# view_level is NOT reported by the read API => must be omitted, not shipped as "full".
assert "view_level" not in sc
mock_client.sharing.get_status.assert_awaited_once_with(NB_ID)
async def test_share_status_resolves_notebook_by_name(mcp_call, mock_client) -> None:
mock_client.notebooks.list = AsyncMock(
return_value=[type("NB", (), {"id": NB_ID, "title": "My NB"})()]
)
mock_client.sharing.get_status = AsyncMock(return_value=FakeShareStatus())
await mcp_call("share_status", {"notebook": "My NB"})
mock_client.sharing.get_status.assert_awaited_once_with(NB_ID)
# ---------------------------------------------------------------------------
# share_set_access — folds set_public + set_view_level
# ---------------------------------------------------------------------------
async def test_share_set_access_public_only(mcp_call, mock_client) -> None:
# confirm=True bypasses the widening gate → straight to set_public (no get_status).
mock_client.sharing.set_public = AsyncMock(
return_value=FakeShareStatus(is_public=True, access=ShareAccess.ANYONE_WITH_LINK)
)
mock_client.sharing.set_view_level = AsyncMock()
mock_client.sharing.get_status = AsyncMock()
result = await mcp_call(
"share_set_access", {"notebook": NB_ID, "public": True, "confirm": True}
)
assert result.structured_content["access"] == "anyone_with_link"
assert "view_level" not in result.structured_content # not set => omitted
mock_client.sharing.set_public.assert_awaited_once_with(NB_ID, True)
mock_client.sharing.set_view_level.assert_not_called()
# confirm=True skips the widening state-check read entirely.
mock_client.sharing.get_status.assert_not_called()
async def test_share_set_access_view_level_only_returns_value(mcp_call, mock_client) -> None:
"""Guards the read-limitation trap: view_level echoes the value it set."""
mock_client.sharing.set_public = AsyncMock()
mock_client.sharing.set_view_level = AsyncMock(
return_value=FakeShareStatus(view_level=ShareViewLevel.CHAT_ONLY)
)
result = await mcp_call("share_set_access", {"notebook": NB_ID, "view_level": "chat"})
assert result.structured_content["view_level"] == "chat"
mock_client.sharing.set_public.assert_not_called()
mock_client.sharing.set_view_level.assert_awaited_once_with(NB_ID, ShareViewLevel.CHAT_ONLY)
async def test_share_set_access_both_fail_closed_order_returns_view_level(
mcp_call, mock_client
) -> None:
"""Both fields: view_level (the restriction) is applied FIRST (fail-closed), and the
response echoes the just-set view_level from set_view_level, not set_public's
FULL-hardcoded status."""
mock_client.sharing.set_public = AsyncMock(
return_value=FakeShareStatus(is_public=True, view_level=ShareViewLevel.FULL_NOTEBOOK)
)
mock_client.sharing.set_view_level = AsyncMock(
return_value=FakeShareStatus(view_level=ShareViewLevel.CHAT_ONLY) # authoritative
)
result = await mcp_call(
"share_set_access",
{"notebook": NB_ID, "public": True, "view_level": "chat", "confirm": True},
)
sc = result.structured_content
assert sc["view_level"] == "chat" # from set_view_level, not set_public's FULL
assert sc["is_public"] is True # from set_public (applied last, authoritative)
mock_client.sharing.set_public.assert_awaited_once_with(NB_ID, True)
mock_client.sharing.set_view_level.assert_awaited_once_with(NB_ID, ShareViewLevel.CHAT_ONLY)
# Fail-closed: the restricting view_level is applied BEFORE toggling public.
call_names = [c[0] for c in mock_client.sharing.mock_calls]
assert call_names.index("set_view_level") < call_names.index("set_public")
async def test_share_set_access_requires_a_field(mcp_call, mock_client) -> None:
mock_client.sharing.set_public = AsyncMock()
with pytest.raises(ToolError):
await mcp_call("share_set_access", {"notebook": NB_ID})
mock_client.sharing.set_public.assert_not_called()
async def test_share_set_access_rejects_bad_view_level(mcp_call, mock_client) -> None:
mock_client.sharing.set_view_level = AsyncMock()
with pytest.raises(ToolError):
await mcp_call("share_set_access", {"notebook": NB_ID, "view_level": "sources"})
mock_client.sharing.set_view_level.assert_not_called()
# --- confirm gate on public widening (restricted -> public) ---
async def test_share_set_access_widening_needs_confirmation(mcp_call, mock_client) -> None:
"""public=True on a currently-restricted notebook, no confirm => preview, no mutation."""
mock_client.sharing.get_status = AsyncMock(return_value=FakeShareStatus(is_public=False))
mock_client.sharing.set_public = AsyncMock()
result = await mcp_call("share_set_access", {"notebook": NB_ID, "public": True})
assert result.structured_content == {
"status": "needs_confirmation",
"preview": {
"action": "share_set_access",
"notebook_id": NB_ID,
"change": "restricted -> anyone_with_link",
},
}
mock_client.sharing.set_public.assert_not_called()
mock_client.sharing.get_status.assert_awaited_once_with(NB_ID)
async def test_share_set_access_widening_preview_includes_view_level(mcp_call, mock_client) -> None:
"""A widening call that also sets view_level surfaces it in the preview."""
mock_client.sharing.get_status = AsyncMock(return_value=FakeShareStatus(is_public=False))
mock_client.sharing.set_public = AsyncMock()
mock_client.sharing.set_view_level = AsyncMock()
result = await mcp_call(
"share_set_access", {"notebook": NB_ID, "public": True, "view_level": "chat"}
)
assert result.structured_content["status"] == "needs_confirmation"
assert result.structured_content["preview"]["view_level"] == "chat"
mock_client.sharing.set_public.assert_not_called()
mock_client.sharing.set_view_level.assert_not_called()
async def test_share_set_access_already_public_needs_no_confirmation(mcp_call, mock_client) -> None:
"""public=True on an already-public notebook is NOT a widening => applies directly."""
mock_client.sharing.get_status = AsyncMock(return_value=FakeShareStatus(is_public=True))
mock_client.sharing.set_public = AsyncMock(
return_value=FakeShareStatus(is_public=True, access=ShareAccess.ANYONE_WITH_LINK)
)
result = await mcp_call("share_set_access", {"notebook": NB_ID, "public": True})
assert result.structured_content["status"] == "updated"
mock_client.sharing.set_public.assert_awaited_once_with(NB_ID, True)
# The single state-read that discovered "already public" is pinned so a refactor
# that skips it (and would then wrongly gate) is caught.
mock_client.sharing.get_status.assert_awaited_once_with(NB_ID)
async def test_share_set_access_restricting_needs_no_confirmation(mcp_call, mock_client) -> None:
"""public=False (restricting) is never gated — no state read, applies directly."""
mock_client.sharing.get_status = AsyncMock()
mock_client.sharing.set_public = AsyncMock(
return_value=FakeShareStatus(is_public=False, access=ShareAccess.RESTRICTED)
)
result = await mcp_call("share_set_access", {"notebook": NB_ID, "public": False})
assert result.structured_content["access"] == "restricted"
mock_client.sharing.set_public.assert_awaited_once_with(NB_ID, False)
mock_client.sharing.get_status.assert_not_called()
async def test_share_set_access_view_level_only_needs_no_confirmation(
mcp_call, mock_client
) -> None:
"""view_level-only (public is None) is not gated — no state read."""
mock_client.sharing.get_status = AsyncMock()
mock_client.sharing.set_view_level = AsyncMock(
return_value=FakeShareStatus(view_level=ShareViewLevel.CHAT_ONLY)
)
result = await mcp_call("share_set_access", {"notebook": NB_ID, "view_level": "chat"})
assert result.structured_content["view_level"] == "chat"
mock_client.sharing.get_status.assert_not_called()
# ---------------------------------------------------------------------------
# share_set_user — upsert over add_user (add + update are the same RPC)
# ---------------------------------------------------------------------------
async def test_share_set_user_needs_confirmation(mcp_call, mock_client) -> None:
"""Every grant is gated: no confirm => preview (with has_message), no mutation."""
mock_client.sharing.add_user = AsyncMock()
result = await mcp_call(
"share_set_user", {"notebook": NB_ID, "email": "a@b.com", "message": "hi"}
)
assert result.structured_content == {
"status": "needs_confirmation",
"preview": {
"action": "share_set_user",
"notebook_id": NB_ID,
"email": "a@b.com",
"permission": "viewer",
"notify": False,
"has_message": True,
},
}
mock_client.sharing.add_user.assert_not_called()
async def test_share_set_user_defaults_viewer(mcp_call, mock_client) -> None:
# confirm=True applies the grant; notify defaults to False (no email spam, #1742).
mock_client.sharing.add_user = AsyncMock(
return_value=FakeShareStatus(
shared_users=[FakeSharedUser(email="a@b.com", permission=SharePermission.VIEWER)]
)
)
result = await mcp_call(
"share_set_user", {"notebook": NB_ID, "email": "a@b.com", "confirm": True}
)
assert result.structured_content["shared_users"][0]["permission"] == "viewer"
assert "view_level" not in result.structured_content
mock_client.sharing.add_user.assert_awaited_once_with(
NB_ID, "a@b.com", permission=SharePermission.VIEWER, notify=False, welcome_message=""
)
async def test_share_set_user_editor_with_message(mcp_call, mock_client) -> None:
mock_client.sharing.add_user = AsyncMock(return_value=FakeShareStatus())
await mcp_call(
"share_set_user",
{
"notebook": NB_ID,
"email": "a@b.com",
"permission": "editor",
"notify": True,
"message": "welcome",
"confirm": True,
},
)
mock_client.sharing.add_user.assert_awaited_once_with(
NB_ID, "a@b.com", permission=SharePermission.EDITOR, notify=True, welcome_message="welcome"
)
async def test_share_set_user_rejects_owner(mcp_call, mock_client) -> None:
"""OWNER is not a valid input (Literal editor|viewer) => schema rejection, no RPC."""
mock_client.sharing.add_user = AsyncMock()
with pytest.raises(ToolError):
await mcp_call(
"share_set_user", {"notebook": NB_ID, "email": "a@b.com", "permission": "owner"}
)
mock_client.sharing.add_user.assert_not_called()
# ---------------------------------------------------------------------------
# share_remove_user — confirm-gated
# ---------------------------------------------------------------------------
async def test_share_remove_user_needs_confirmation(mcp_call, mock_client) -> None:
mock_client.sharing.remove_user = AsyncMock()
result = await mcp_call("share_remove_user", {"notebook": NB_ID, "email": "a@b.com"})
sc = result.structured_content
assert sc["status"] == "needs_confirmation"
assert sc["preview"] == {
"action": "remove_share_user",
"notebook_id": NB_ID,
"email": "a@b.com",
}
mock_client.sharing.remove_user.assert_not_called()
async def test_share_remove_user_confirmed(mcp_call, mock_client) -> None:
# The tool discards remove_user's return value, so no return_value is set here.
mock_client.sharing.remove_user = AsyncMock()
result = await mcp_call(
"share_remove_user", {"notebook": NB_ID, "email": "a@b.com", "confirm": True}
)
assert result.structured_content == {
"status": "removed",
"notebook_id": NB_ID,
"email": "a@b.com",
}
mock_client.sharing.remove_user.assert_awaited_once_with(NB_ID, "a@b.com")