3ffdbbc521
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 34s
CI & Build / Build & push image (push) Has been skipped
Option B, per the operator. Closes the last inconsistency from the ACL work.
The agent path had drifted into an indefensible position: delete_snippet honoured
editor shares (I made it share-aware so widening the read wouldn't let a VIEWER
trash things) while update_snippet still resolved through the owner-only
notes.get_note. So through an agent you could destroy a colleague's snippet but
not improve it — and the refusal claimed "not found" for a record you could
plainly open.
Now update_snippet, merge_snippets and update_process all resolve the read scope
and then require can_write_note, matching the REST routes and the sharing UI's
own promise that viewer / editor / admin are distinct grants. A viewer grant is
refused with the actual reason ("shared with you read-only — ask its owner for
edit access, or record your own version"), because not-found would send an agent
hunting for a missing id instead of recording its own copy.
Authorised writes are performed as the OWNER, since the underlying note update is
owner-scoped and a shared editor's own id would match nothing.
Merge additionally requires each source to share the TARGET'S owner and to be
writable by the caller — merging trashes the source, so read access isn't enough,
and cross-owner merge stays out of scope (#231). Sources failing either test are
skipped rather than half-merged.
A record the caller cannot read at all still returns not-found rather than
forbidden, so the error can't be used to confirm that an id exists.
Also fixed _fake_snippet's missing user_id proactively — the same
auto-MagicMock-reads-as-foreign trap that broke CI twice (see note 2109).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
229 lines
9.1 KiB
Python
229 lines
9.1 KiB
Python
"""Tests for MCP snippet tools — patches the service layer."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.mcp._context import _user_id_ctx
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _bind_user():
|
|
token = _user_id_ctx.set(7)
|
|
yield
|
|
_user_id_ctx.reset(token)
|
|
|
|
|
|
def _fake_snippet(user_id: int = 7):
|
|
n = MagicMock()
|
|
n.id = 1
|
|
n.title = "debounce — rate-limit a callback"
|
|
n.body = "```js\nreturn 1\n```\n"
|
|
n.tags = ["js", "snippet"]
|
|
n.note_type = "snippet"
|
|
# Real int, matching the bound caller by default. The tools compare it to
|
|
# decide whether to attach a shared/owner marker; an auto-MagicMock would read
|
|
# as another user's record and send them off to look up a username.
|
|
n.user_id = user_id
|
|
n.to_dict.return_value = {
|
|
"id": 1, "title": n.title, "note_type": "snippet", "tags": n.tags,
|
|
}
|
|
return n
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_snippet_requires_name_and_code():
|
|
from scribe.mcp.tools.snippets import create_snippet
|
|
with pytest.raises(ValueError):
|
|
await create_snippet(name="", code="x")
|
|
with pytest.raises(ValueError):
|
|
await create_snippet(name="x", code=" ")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_snippet_records_and_returns_parsed():
|
|
created = _fake_snippet()
|
|
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
|
|
patch("scribe.services.snippets.create_snippet",
|
|
AsyncMock(return_value=created)) as mock_create:
|
|
from scribe.mcp.tools.snippets import create_snippet
|
|
out = await create_snippet(
|
|
name="debounce", code="return 1", language="js",
|
|
when_to_use="rate-limit a callback",
|
|
)
|
|
assert out["note_type"] == "snippet"
|
|
assert out["snippet"]["name"] == "debounce"
|
|
assert out["snippet"]["language"] == "js"
|
|
assert mock_create.await_args.kwargs["name"] == "debounce"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_snippet_dedup_blocks_and_labels_snippet():
|
|
dup = MagicMock(id=99)
|
|
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=dup)), \
|
|
patch("scribe.services.dedup.duplicate_response",
|
|
MagicMock(return_value={"duplicate": True, "existing_id": 99})) as mock_resp, \
|
|
patch("scribe.services.snippets.create_snippet", AsyncMock()) as mock_create:
|
|
from scribe.mcp.tools.snippets import create_snippet
|
|
out = await create_snippet(name="debounce", code="return 1")
|
|
assert out["duplicate"] is True
|
|
mock_create.assert_not_awaited()
|
|
assert mock_resp.call_args.args[1] == "snippet"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_snippet_not_found_raises():
|
|
with patch("scribe.services.snippets.get_snippet", AsyncMock(return_value=None)):
|
|
from scribe.mcp.tools.snippets import get_snippet
|
|
with pytest.raises(ValueError):
|
|
await get_snippet(123)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_snippet_missing_raises():
|
|
with patch("scribe.services.snippets.update_snippet", AsyncMock(return_value=None)):
|
|
from scribe.mcp.tools.snippets import update_snippet
|
|
with pytest.raises(ValueError):
|
|
await update_snippet(123, name="x")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_snippet_empty_string_clears_a_field():
|
|
# An omitted field must stay None ("leave alone"), but an explicit empty
|
|
# string has to reach the service as "" so a stale field can be removed.
|
|
updated = _fake_snippet()
|
|
with patch("scribe.services.snippets.update_snippet",
|
|
AsyncMock(return_value=updated)) as mock_update:
|
|
from scribe.mcp.tools.snippets import update_snippet
|
|
await update_snippet(1, signature="")
|
|
kwargs = mock_update.await_args.kwargs
|
|
assert kwargs["signature"] == "" # cleared
|
|
assert kwargs["name"] is None # untouched
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_snippet_project_id_conventions():
|
|
from scribe.services import snippets as snippets_svc
|
|
updated = _fake_snippet()
|
|
cases = {0: snippets_svc.UNSET, -1: None, 5: 5}
|
|
for given, expected in cases.items():
|
|
with patch("scribe.services.snippets.update_snippet",
|
|
AsyncMock(return_value=updated)) as mock_update:
|
|
from scribe.mcp.tools.snippets import update_snippet
|
|
await update_snippet(1, project_id=given)
|
|
assert mock_update.await_args.kwargs["project_id"] is expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_and_update_pass_locations_through():
|
|
locs = [{"repo": "a", "path": "a.py", "symbol": "f"},
|
|
{"repo": "b", "path": "b.py", "symbol": "g"}]
|
|
created = _fake_snippet()
|
|
with patch("scribe.services.dedup.find_duplicate_note", AsyncMock(return_value=None)), \
|
|
patch("scribe.services.snippets.create_snippet",
|
|
AsyncMock(return_value=created)) as mock_create:
|
|
from scribe.mcp.tools.snippets import create_snippet
|
|
await create_snippet(name="f", code="x", locations=locs)
|
|
assert mock_create.await_args.kwargs["locations"] == locs
|
|
|
|
with patch("scribe.services.snippets.update_snippet",
|
|
AsyncMock(return_value=created)) as mock_update:
|
|
from scribe.mcp.tools.snippets import update_snippet
|
|
await update_snippet(1, locations=locs)
|
|
assert mock_update.await_args.kwargs["locations"] == locs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_snippet_read_only_share_says_why():
|
|
"""A viewer grant must be refused with the REAL reason. "Not found" would be
|
|
a lie about a record the caller can plainly open, and would send an agent
|
|
hunting for a missing id instead of recording its own version."""
|
|
with patch("scribe.services.snippets.update_snippet",
|
|
AsyncMock(side_effect=PermissionError(
|
|
"snippet 1 is shared with you read-only — ask its owner for "
|
|
"edit access, or record your own version"))):
|
|
from scribe.mcp.tools.snippets import update_snippet
|
|
with pytest.raises(ValueError, match="read-only"):
|
|
await update_snippet(1, name="x")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_snippets_read_only_target_says_why():
|
|
with patch("scribe.services.snippets.merge_snippets",
|
|
AsyncMock(side_effect=PermissionError(
|
|
"snippet 1 is shared with you read-only — you can't merge "
|
|
"into a record you can't edit"))):
|
|
from scribe.mcp.tools.snippets import merge_snippets
|
|
with pytest.raises(ValueError, match="read-only"):
|
|
await merge_snippets(target_id=1, source_ids=[2])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_snippet_retires_or_raises():
|
|
from scribe.mcp.tools.snippets import delete_snippet
|
|
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=True)):
|
|
assert await delete_snippet(1) == {"deleted": True, "id": 1}
|
|
with patch("scribe.services.snippets.delete_snippet", AsyncMock(return_value=False)):
|
|
with pytest.raises(ValueError):
|
|
await delete_snippet(404)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_snippets_defaults_to_every_project():
|
|
with patch("scribe.services.snippets.list_snippets",
|
|
AsyncMock(return_value=([], 0))) as mock_list:
|
|
from scribe.mcp.tools.snippets import list_snippets
|
|
await list_snippets(q="debounce")
|
|
assert mock_list.await_args.kwargs["project_id"] is None
|
|
await list_snippets(q="debounce", project_id=3)
|
|
assert mock_list.await_args.kwargs["project_id"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_snippets_requires_a_source():
|
|
from scribe.mcp.tools.snippets import merge_snippets
|
|
with pytest.raises(ValueError):
|
|
await merge_snippets(target_id=1, source_ids=[])
|
|
with pytest.raises(ValueError):
|
|
await merge_snippets(target_id=1, source_ids=[1]) # only self → nothing to merge
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_snippets_returns_survivor_and_merged_ids():
|
|
survivor = _fake_snippet()
|
|
with patch("scribe.services.snippets.merge_snippets",
|
|
AsyncMock(return_value=(survivor, [2, 3]))) as mock_merge:
|
|
from scribe.mcp.tools.snippets import merge_snippets
|
|
out = await merge_snippets(target_id=1, source_ids=[2, 3, 1])
|
|
assert out["merged_ids"] == [2, 3]
|
|
assert out["snippet"]["name"] == "debounce"
|
|
# target_id passed through; self-id filtered out of the source list.
|
|
assert mock_merge.await_args.args[1] == 1
|
|
assert mock_merge.await_args.args[2] == [2, 3]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_snippets_not_found_raises():
|
|
with patch("scribe.services.snippets.merge_snippets", AsyncMock(return_value=None)):
|
|
from scribe.mcp.tools.snippets import merge_snippets
|
|
with pytest.raises(ValueError):
|
|
await merge_snippets(target_id=1, source_ids=[2])
|
|
|
|
|
|
def test_register_attaches_all_tools():
|
|
from scribe.mcp.tools import snippets
|
|
names: list[str] = []
|
|
|
|
class FakeMcp:
|
|
def tool(self, name):
|
|
names.append(name)
|
|
|
|
def deco(fn):
|
|
return fn
|
|
return deco
|
|
|
|
snippets.register(FakeMcp())
|
|
assert set(names) == {
|
|
"list_snippets", "create_snippet", "get_snippet", "update_snippet",
|
|
"delete_snippet", "merge_snippets",
|
|
}
|