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:
tao.chen
2026-08-21 11:17:45 +08:00
parent 79650c61ed
commit ffec234e40
6 changed files with 312 additions and 120 deletions
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "~/context/AuthContext";
import { DashboardPage } from "../../components/admin/DashboardPage";
import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
@@ -9,14 +10,15 @@ export default function DashboardRoute() {
const scriptCount = useScriptWorkspaceStore((s) => s.scriptCount);
const loadScriptCount = useScriptWorkspaceStore((s) => s.loadScriptCount);
const apiOnline = useScriptWorkspaceStore((s) => s.apiOnline);
const workspaceId = useAuth().currentWorkspace?.workspace_id;
const navigate = useNavigate();
// Independent of the lazy-loaded `scripts` array — the count endpoint
// returns the workspace-wide total even when no folders have been
// expanded yet (see #34 + #37).
// Reload on workspace switch — DashboardRoute is not keyed by
// workspace/user (only ScriptsPage is), so without this dep the
// previous workspace's count would persist.
useEffect(() => {
void loadScriptCount();
}, [loadScriptCount]);
}, [loadScriptCount, workspaceId]);
return (
<DashboardPage
@@ -398,7 +398,7 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
loadScriptCount: async () => {
const api = requireApi();
if (get().scriptCountLoading) return;
set({ scriptCountLoading: true });
set({ scriptCountLoading: true, scriptCount: null });
try {
const total = await api.countScripts();
set({ scriptCount: total });