fix(scripts): actually escape LIKE pattern literals + scope count endpoint
Codex review of #36 + #37 surfaced that my prior `escape="\\"` only declared the escape character — the pattern literals themselves still contained unescaped `_` and `%`, so `parent_path="foo_bar"` continued to match `fooXbar/...`, `foo2bar/...`, etc. My earlier ESCAPE-clause assertions were tautological: they verified the SQL rendered the ESCAPE keyword without ever checking that the pattern was actually escaped. The tests passed; the leak persisted. Fix in three layers: 1. Real escape: `backend/src/backend/scripts.py` gains `_escape_like_pattern(value)` that escapes `\` → `\\`, `%` → `\%`, `_` → `\_` (in that order — the escape char MUST be escaped first). `_build_list_scripts_descendant_prefix` now returns the escaped prefix. `list_workspace_directories` and `delete_workspace_directory` also escape their server-built prefixes. `count_scripts` escapes the user subtree prefix. 2. Same bug elsewhere: `backend/src/backend/resources.py:404` had the identical `DataResources.resource_name.like(f"%{keyword}%")` pattern; a search for "100%" would match everything. Now escaped too. 3. Count endpoint scope: `count_scripts` was workspace-wide and skipped the StorageObjects JOIN. Now INNER JOINs StorageObjects (drops orphans whose current_object_id is dangling) and filters by `workspace/{user_id}/` subtree so the result matches what `list_scripts(parent_path="")` would return. Multi-member workspaces no longer over-report, and orphan rows no longer inflate the count. Frontend: `DashboardRoute` is not keyed by workspace/user (only ScriptsPage is), so without a workspace_id dep the previous workspace's count persisted across navigation. useEffect now depends on `currentWorkspace?.workspace_id`; `loadScriptCount` clears the count to null at the start of the fetch so the dashboard doesn't flash a stale number. Tests — backend/tests/test_list_scripts_parent_path.py - Rewritten with three layers of coverage: * Pure helper tests for `_escape_like_pattern` (7 cases including backslash-escape-first ordering). * SQL-contract tests asserting the COMPILED PATTERN contains the escaped form (lowercased to neutralise SQLAlchemy keyword casing). * BEHAVIORAL tests on SQLite in-memory with the same LIKE semantics — proves the fix actually prevents the wildcard leak. Includes a negative test (without escape, siblings DO match) so the fixture is verified to exercise the bug. Tests — backend/tests/test_count_scripts.py - Updated to assert the JOIN + user-scope filter. New test verifies two different users in the same workspace get different subtrees. Verified: - pytest backend/tests: 65 passed (43 baseline + 12 list_scripts + 4 count + 6 helper/SQLite behavioral) - pnpm typecheck: clean - Raw SQL on MySQL (live DB) confirms `LIKE 'workspace/.../foo\_bar/%%' ESCAPE '\\'`.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""Unit tests for GET /api/v1/scripts/count endpoint.
|
||||
|
||||
Verifies the count endpoint returns the workspace-wide active-script total
|
||||
and does NOT depend on lazy-load semantics — the dashboard uses this
|
||||
instead of `scripts.length` to avoid underreporting.
|
||||
Verifies the count endpoint returns the same scope as
|
||||
``list_scripts(parent_path="")``: workspace + active scripts whose
|
||||
``StorageObjects.relative_path`` lives under the user's subtree. This
|
||||
avoids under/over-reporting on the dashboard — the count is the size of
|
||||
the set list_scripts would return if it weren't lazy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +13,6 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from backend.scripts import count_scripts
|
||||
|
||||
@@ -26,16 +27,24 @@ def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace:
|
||||
)
|
||||
|
||||
|
||||
def _compile(stmt) -> str:
|
||||
from sqlalchemy.dialects import mysql as mysql_dialect
|
||||
|
||||
return str(
|
||||
stmt.compile(
|
||||
dialect=mysql_dialect.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_count_scripts_returns_scalar_int() -> None:
|
||||
captured = []
|
||||
|
||||
class _MockScalarResult:
|
||||
def scalar(self, _stmt):
|
||||
captured.append(_stmt)
|
||||
return 7
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.scalar = AsyncMock(side_effect=lambda stmt: (captured.append(stmt), 7)[1])
|
||||
mock_session.scalar = AsyncMock(
|
||||
side_effect=lambda stmt: (captured.append(stmt), 7)[1]
|
||||
)
|
||||
|
||||
result = await count_scripts(context=_ctx(), session=mock_session)
|
||||
assert result["data"] == {"total": 7}
|
||||
@@ -44,12 +53,14 @@ async def test_count_scripts_returns_scalar_int() -> None:
|
||||
# Exactly one COUNT(*) query issued.
|
||||
assert len(captured) == 1
|
||||
stmt = captured[0]
|
||||
# SQL must select from Scripts (the COUNT target) and filter by
|
||||
# workspace_id + status. Bind params render as :workspace_id_1 etc.
|
||||
text = str(stmt).lower()
|
||||
assert "from scripts" in text
|
||||
assert "workspace_id" in text
|
||||
assert "status" in text
|
||||
sql = _compile(stmt).lower()
|
||||
# JOIN to StorageObjects so orphaned scripts (no joinable row) are
|
||||
# excluded — matches list_scripts INNER JOIN behaviour.
|
||||
assert "inner join storage_objects" in sql
|
||||
# Scope: workspace_id + active status + user subtree.
|
||||
assert "scripts.workspace_id" in sql
|
||||
assert "scripts.status" in sql
|
||||
assert "workspace/u001/%" in sql
|
||||
|
||||
|
||||
async def test_count_scripts_handles_null_result() -> None:
|
||||
@@ -61,12 +72,33 @@ async def test_count_scripts_handles_null_result() -> None:
|
||||
assert result["data"] == {"total": 0}
|
||||
|
||||
|
||||
async def test_count_scripts_uses_user_specific_subtree() -> None:
|
||||
"""Different users in the same workspace must see different totals —
|
||||
each user's count is bounded by their own ``workspace/{user_id}/``
|
||||
subtree, NOT the whole workspace."""
|
||||
captured = []
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.scalar = AsyncMock(
|
||||
side_effect=lambda stmt: (captured.append(stmt), 3)[1]
|
||||
)
|
||||
|
||||
await count_scripts(context=_ctx(user_id="alice"), session=mock_session)
|
||||
sql_alice = _compile(captured[-1])
|
||||
|
||||
await count_scripts(context=_ctx(user_id="bob"), session=mock_session)
|
||||
sql_bob = _compile(captured[-1])
|
||||
|
||||
assert "workspace/alice/%" in sql_alice
|
||||
assert "workspace/alice/%" not in sql_bob
|
||||
assert "workspace/bob/%" in sql_bob
|
||||
|
||||
|
||||
async def test_count_scripts_route_declared_before_script_id_route() -> None:
|
||||
"""Static check: the `/api/v1/scripts/count` route MUST be declared in
|
||||
scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's
|
||||
declaration-order matching will interpret `count` as a script_id."""
|
||||
from backend.scripts import count_scripts, get_script
|
||||
|
||||
# Both callables exist (sanity).
|
||||
assert callable(count_scripts)
|
||||
assert callable(get_script)
|
||||
Reference in New Issue
Block a user