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,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 });
|
||||
|
||||
Reference in New Issue
Block a user