CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Operator's call, and it corrects a scope error rather than a bug: "this is a path for a user to go from a rulebook to a design system. we don't need to build this path in the app itself ... you should be the one that does the import ... going forward no one else should have to do such a migration." Right. Nobody starting from a design system will ever go rulebook -> system, so the whole path was permanent product code serving a single act on one install. Rule #22: remove it, don't flag it off. Gone from the service, the REST route, the MCP tool, the UI panel, the API client and its tests. There is a second consequence I had missed, and it is the better argument. The parser was WORSE at this than doing it by hand. `propose_tokens` leaves radius steps and type sizes valueless because "Small 4px" is not a hex and nothing here parses it — a limitation I documented carefully and shipped anyway. But that limitation only exists because the importer had to run unattended. Done as work rather than as a feature, those values are just read and written, and the result is a complete design system instead of one with a dozen blanks and a count explaining them. Scaffolding built around my own absence from the loop, when I am the loop. KEPT: `extract_expectations` and `design_expectations` in services/design_rulebook_import.py. The live drift panel still reads them until it is repointed at a resolved design system (#2295), and removing them now would take the /design page's only content with it. They go with that change, not this one.
324 lines
14 KiB
Python
324 lines
14 KiB
Python
"""ACL gating and field handling for services/design_systems.py (unit, mocked).
|
|
|
|
Mirrors tests/test_services_systems.py — same mocked-session shape, because the
|
|
question is the same one: does the service refuse before it touches a row?
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services.design_systems import DesignSystemCycle
|
|
|
|
|
|
def _make_mock_session():
|
|
s = AsyncMock()
|
|
s.__aenter__ = AsyncMock(return_value=s)
|
|
s.__aexit__ = AsyncMock(return_value=False)
|
|
s.add = MagicMock()
|
|
s.commit = AsyncMock()
|
|
s.refresh = AsyncMock()
|
|
return s
|
|
|
|
|
|
# --- creating ---------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_a_parent_is_denied_without_write_on_that_parent():
|
|
"""Parenting to someone else's system would let their delete or re-parent
|
|
silently restyle your app, so the chain may only be built from systems you
|
|
can write — which, per the ACL, means ones you own."""
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import create_design_system
|
|
result = await create_design_system(user_id=1, title="Scribe", parent_id=7)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_without_a_parent_never_consults_the_parent_acl():
|
|
"""A family system has no parent, and creating one must not be gated on a
|
|
permission check for a system that does not exist."""
|
|
mock_session = _make_mock_session()
|
|
captured = {}
|
|
mock_session.add = MagicMock(
|
|
side_effect=lambda obj: captured.update(
|
|
title=obj.title, parent_id=obj.parent_id
|
|
)
|
|
)
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import create_design_system
|
|
await create_design_system(user_id=1, title=" FabledSword ")
|
|
assert captured["title"] == "FabledSword" # stripped
|
|
assert captured["parent_id"] is None
|
|
acc.can_write_design_system.assert_not_awaited()
|
|
|
|
|
|
# --- the cycle guard, through the service -----------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reparenting_into_a_loop_raises_rather_than_returning_none():
|
|
"""None already means "not found, or not yours". A caller that conflated the
|
|
two would report "no such design system" for what is really "that parent is
|
|
one of its own descendants", so the cycle gets its own exception type."""
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=MagicMock(deleted_at=None, parent_id=None))
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc, \
|
|
patch("scribe.services.design_systems._parent_map",
|
|
AsyncMock(return_value={1: None, 2: 1})):
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import update_design_system
|
|
with pytest.raises(DesignSystemCycle):
|
|
await update_design_system(user_id=1, design_system_id=1, parent_id=2)
|
|
|
|
mock_session.commit.assert_not_awaited() # refused BEFORE the write
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clearing_the_parent_is_allowed_and_is_not_read_as_no_change():
|
|
"""`parent_id=None` means "make this a root" — the one field where None is a
|
|
value rather than "leave alone", which is why it is handled apart from the
|
|
others."""
|
|
system = MagicMock(deleted_at=None, parent_id=5)
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=system)
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc, \
|
|
patch("scribe.services.design_systems._parent_map",
|
|
AsyncMock(return_value={1: 5, 5: None})):
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import update_design_system
|
|
await update_design_system(user_id=1, design_system_id=1, parent_id=None)
|
|
|
|
assert system.parent_id is None
|
|
|
|
|
|
# --- tokens -----------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_denied_without_write_on_the_system():
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import create_token
|
|
result = await create_token(user_id=1, design_system_id=3, name="--fs-obsidian")
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_values_default_to_an_empty_map_not_json_null():
|
|
"""The column is NOT NULL so that absence has exactly ONE spelling. Passing
|
|
None straight through would store JSON null and hand every reader back the
|
|
second empty state the schema was shaped to remove."""
|
|
mock_session = _make_mock_session()
|
|
captured = {}
|
|
mock_session.add = MagicMock(
|
|
side_effect=lambda obj: captured.update(value_by_mode=obj.value_by_mode)
|
|
)
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import create_token
|
|
await create_token(
|
|
user_id=1, design_system_id=3, name="--fs-radius-md", value_by_mode=None
|
|
)
|
|
|
|
assert captured["value_by_mode"] == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tokens_denied_returns_empty():
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_read_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import list_tokens
|
|
assert await list_tokens(user_id=1, design_system_id=3) == []
|
|
|
|
|
|
# --- the project pointer ----------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pointing_a_project_at_a_system_needs_write_on_the_project():
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_project = AsyncMock(return_value=False)
|
|
acc.can_read_design_system = AsyncMock(return_value=True)
|
|
from scribe.services.design_systems import set_project_design_system
|
|
assert await set_project_design_system(1, project_id=5, design_system_id=3) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pointing_a_project_needs_only_READ_on_the_system():
|
|
"""Consuming a design system is not changing it, so a system reachable
|
|
through another project is a legitimate choice here. Requiring write would
|
|
make a shared family style unusable by the people it was shared with."""
|
|
project = MagicMock(deleted_at=None, design_system_id=None)
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=project)
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_project = AsyncMock(return_value=True)
|
|
acc.can_read_design_system = AsyncMock(return_value=True)
|
|
acc.can_write_design_system = AsyncMock(return_value=False)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import set_project_design_system
|
|
assert await set_project_design_system(1, project_id=5, design_system_id=3) is True
|
|
|
|
assert project.design_system_id == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clearing_a_projects_design_system_skips_the_system_acl():
|
|
"""Un-styling a project must not require permission on the system it is
|
|
letting go of — including one that has since been deleted."""
|
|
project = MagicMock(deleted_at=None, design_system_id=3)
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=project)
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_project = AsyncMock(return_value=True)
|
|
acc.can_read_design_system = AsyncMock(return_value=False)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import set_project_design_system
|
|
assert await set_project_design_system(1, project_id=5, design_system_id=None) is True
|
|
|
|
assert project.design_system_id is None
|
|
acc.can_read_design_system.assert_not_awaited()
|
|
|
|
|
|
# --- resolution (step 2) ----------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_denied_returns_none_not_an_empty_set():
|
|
"""None and [] mean different things here: "you may not see this" versus
|
|
"this chain genuinely holds no tokens". A caller that got [] for a denial
|
|
would render an empty design system as though it were real."""
|
|
with patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_read_design_system = AsyncMock(return_value=False)
|
|
from scribe.services.design_systems import resolve_design_system
|
|
assert await resolve_design_system(user_id=1, design_system_id=3) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_scopes_the_hierarchy_to_the_systems_OWNER_not_the_caller():
|
|
"""LOAD-BEARING for shared projects. A caller reading through a project
|
|
shared with them owns no link in the chain, so a caller-scoped parent map
|
|
returns an empty forest and the cascade truncates to one system — a page
|
|
that renders with plausible wrong values and no error anywhere.
|
|
|
|
The ACL already grants read on the whole chain (reachability propagates
|
|
upward); this is the loading side keeping that promise.
|
|
"""
|
|
owner, caller = 42, 7
|
|
system = MagicMock(deleted_at=None, owner_user_id=owner)
|
|
mock_session = _make_mock_session()
|
|
mock_session.get = AsyncMock(return_value=system)
|
|
mock_session.execute = AsyncMock(
|
|
return_value=MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: [])))
|
|
)
|
|
parent_map = AsyncMock(return_value={3: None})
|
|
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems._parent_map", parent_map), \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_read_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import resolve_design_system
|
|
result = await resolve_design_system(user_id=caller, design_system_id=3)
|
|
|
|
assert result == [] # empty chain, not None
|
|
assert parent_map.await_args.args[1] == owner # NOT `caller`
|
|
|
|
|
|
# --- supersedes (step 6's reframe) ------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_supersedes_defaults_to_an_empty_list_not_json_null():
|
|
"""Same NOT NULL reasoning as value_by_mode: absence gets one spelling."""
|
|
mock_session = _make_mock_session()
|
|
captured = {}
|
|
mock_session.add = MagicMock(
|
|
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
|
)
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import create_token
|
|
await create_token(user_id=1, design_system_id=3, name="--fs-x", supersedes=None)
|
|
assert captured["supersedes"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_token_records_the_literals_it_replaces():
|
|
mock_session = _make_mock_session()
|
|
captured = {}
|
|
mock_session.add = MagicMock(
|
|
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
|
)
|
|
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
|
patch("scribe.services.design_systems.access") as acc:
|
|
acc.can_write_design_system = AsyncMock(return_value=True)
|
|
mock_cls.return_value = mock_session
|
|
from scribe.services.design_systems import create_token
|
|
await create_token(
|
|
user_id=1, design_system_id=3, name="--fs-text-on-action",
|
|
value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
|
|
)
|
|
assert captured["supersedes"] == ["#fff", "#ffffff"]
|
|
|
|
|
|
# --- the master sheet -------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stylesheet_denied_returns_none():
|
|
with patch("scribe.services.design_systems.resolve_design_system",
|
|
AsyncMock(return_value=None)):
|
|
from scribe.services.design_systems import stylesheet_for_system
|
|
assert await stylesheet_for_system(1, 3) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
|
|
"""The CSS alone hides two things a reviewer needs: which tokens are still
|
|
valueless, and which values are declared twice. Both ride alongside rather
|
|
than being left for the reader to derive from the text."""
|
|
from scribe.services.design_cascade import Contribution, ResolvedToken
|
|
|
|
def _resolved(name, base=None):
|
|
return ResolvedToken(
|
|
name=name,
|
|
contributions=(
|
|
{"base": (Contribution(system_id=1, value=base),)} if base else {}
|
|
),
|
|
group_name=None, purpose=None, rationale=None,
|
|
supersedes=(), order_index=0,
|
|
)
|
|
|
|
system = MagicMock()
|
|
system.title = "FabledSword"
|
|
with patch("scribe.services.design_systems.resolve_design_system",
|
|
AsyncMock(return_value=[
|
|
_resolved("--fs-moss", "#4a5d3f"),
|
|
_resolved("--fs-success", "#4a5d3f"),
|
|
_resolved("--fs-radius-sm"),
|
|
])), \
|
|
patch("scribe.services.design_systems.get_design_system",
|
|
AsyncMock(return_value=system)):
|
|
from scribe.services.design_systems import stylesheet_for_system
|
|
result = await stylesheet_for_system(1, 3)
|
|
|
|
assert result["token_count"] == 3
|
|
assert result["valueless"] == ["--fs-radius-sm"]
|
|
assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
|
|
assert "--fs-moss: #4a5d3f;" in result["css"]
|
|
assert "FabledSword" in result["css"]
|