CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 36s
CI & Build / Build & push image (push) Skipped
Second pass over the tests/ ledger after bbee0d0. fake_record(**attrs) is the
one MagicMock-with-real-attributes builder (to_dict mirrors them; the
note-2109 hazard documented once); fake_note/fake_task/fake_snippet/
fake_project/fake_milestone/fake_system/fake_rulebook/fake_topic/fake_rule
carry each model's ordinary defaults on top of it, replacing 14 per-file
factories (two rulebook trios in tool-vs-service wordings, _fake_task, _fake_ms,
_fake_project, _plan_note, _fake_snippet, two _snippet adapters now one-liners
over fake_snippet). FakeMCP replaces the five closure-over-a-list registrar
fakes (+ _Recorder); loc() and design_token_stub() replace the paired _loc /
_token / _T stand-ins; every hand-built async_session mock (9 helper defs and
14 inline copies) now starts from make_mock_session(). Call sites rewritten by
AST with each file's former defaults made explicit, so behaviour is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
106 lines
4.7 KiB
Python
106 lines
4.7 KiB
Python
"""Editing a shared record: an editor grant is enough, a viewer grant is not.
|
|
|
|
The agent and web paths used to disagree — and worse, within the agent path
|
|
`delete_snippet` honoured editor shares while `update_snippet` did not, so a
|
|
colleague's snippet could be destroyed through an agent but not improved. Both
|
|
now resolve the read scope and then require WRITE, which is what the sharing UI
|
|
already promises: viewer / editor / admin are distinct grants.
|
|
|
|
A record readable-but-not-writable raises PermissionError rather than reporting
|
|
"not found" — the caller can plainly open it, so not-found would be a lie that
|
|
sends them hunting for a missing id.
|
|
"""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from tests.helpers import fake_snippet
|
|
|
|
|
|
def _snippet(id=1, owner=9):
|
|
return fake_snippet(
|
|
id=id, user_id=owner, title="formatDuration — humanize a millisecond count",
|
|
body="```ts\nexport const f = 1\n```\n", tags=["ts", "snippet"],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_editor_share_can_update_and_writes_as_the_owner():
|
|
"""The underlying note update is owner-scoped, so a shared editor's own id
|
|
would match nothing — the authorised write has to be performed as the owner."""
|
|
from scribe.services import snippets as svc
|
|
note = _snippet(owner=9)
|
|
updated = _snippet(owner=9)
|
|
with patch.object(svc, "get_snippet", AsyncMock(return_value=note)), \
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
|
|
patch.object(svc.notes_svc, "update_note",
|
|
AsyncMock(return_value=updated)) as mock_update:
|
|
got = await svc.update_snippet(7, 1, name="formatDuration")
|
|
|
|
assert got is updated
|
|
# Positional user_id is the OWNER (9), not the caller (7).
|
|
assert mock_update.await_args.args[0] == 9
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_viewer_share_cannot_update():
|
|
from scribe.services import snippets as svc
|
|
with patch.object(svc, "get_snippet", AsyncMock(return_value=_snippet())), \
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)), \
|
|
patch.object(svc.notes_svc, "update_note", AsyncMock()) as mock_update:
|
|
with pytest.raises(PermissionError, match="read-only"):
|
|
await svc.update_snippet(7, 1, name="nope")
|
|
mock_update.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_viewer_share_cannot_delete():
|
|
"""delete_snippet returns False rather than raising — its contract is boolean
|
|
— but a viewer must not be able to bin someone else's record."""
|
|
from scribe.services import snippets as svc
|
|
with patch.object(svc, "get_snippet", AsyncMock(return_value=_snippet())), \
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)), \
|
|
patch("scribe.services.trash.delete", AsyncMock()) as mock_trash:
|
|
assert await svc.delete_snippet(7, 1) is False
|
|
mock_trash.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unreadable_record_is_not_found_not_forbidden():
|
|
"""A record the caller can't see at all must NOT be distinguishable from one
|
|
that doesn't exist — otherwise the error itself confirms it exists."""
|
|
from scribe.services import snippets as svc
|
|
with patch.object(svc, "get_snippet", AsyncMock(return_value=None)):
|
|
assert await svc.update_snippet(7, 404, name="x") is None
|
|
assert await svc.delete_snippet(7, 404) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_requires_write_on_target():
|
|
from scribe.services import snippets as svc
|
|
with patch.object(svc, "get_snippet", AsyncMock(return_value=_snippet())), \
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)):
|
|
with pytest.raises(PermissionError, match="read-only"):
|
|
await svc.merge_snippets(7, 1, [2])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_skips_sources_owned_by_someone_else():
|
|
"""Cross-owner merge stays out of scope (#231): a source belonging to a
|
|
different owner than the target is skipped, not silently folded in and
|
|
trashed."""
|
|
from scribe.services import snippets as svc
|
|
target = _snippet(id=1, owner=9)
|
|
same_owner = _snippet(id=2, owner=9)
|
|
other_owner = _snippet(id=3, owner=42)
|
|
|
|
async def fake_get(_uid, sid):
|
|
return {1: target, 2: same_owner, 3: other_owner}.get(sid)
|
|
|
|
with patch.object(svc, "get_snippet", AsyncMock(side_effect=fake_get)), \
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
|
|
patch.object(svc.notes_svc, "update_note", AsyncMock(return_value=target)), \
|
|
patch("scribe.services.trash.delete", AsyncMock(return_value=object())):
|
|
_note, merged_ids = await svc.merge_snippets(7, 1, [2, 3])
|
|
|
|
assert merged_ids == [2]
|