feat(scripts/data-resources): merge tree + add parent_path filter

- Backend: GET /api/v1/data-resources accepts parent_path; LIKE
  '{ws_id}/%/{escaped}/%' AND NOT LIKE '{ws_id}/%/{escaped}/%/%' on
  StorageObjects.object_key (workspace-wide, escapes _ and %, mirrors
  list_scripts parent_path semantics). 13 new tests in
  test_resources.py (helper unit / SQL compile / SQLite behavioral).
- Frontend: listResources gains parentPath arg, propagated through
  WorkspaceBoundApi + AuthContext binding. WorkspaceTreeGroup title
  count and ScriptExplorer header count now include dataResources.
  memberScriptGroups backfills data-only owners so users with only
  data resources still render a group. loadDataResources accepts an
  optional parentPath, default empty preserves prior behavior.
This commit is contained in:
tao.chen
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 0ac034d7d3
commit a337804700
7 changed files with 296 additions and 9 deletions
+238 -1
View File
@@ -7,10 +7,14 @@ from __future__ import annotations
import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from sqlalchemy import Column, MetaData, String, Table, create_engine, select
from sqlalchemy.dialects import mysql as mysql_dialect
from backend.resources import (
_build_list_resources_descendant_prefix,
compute_jupyter_relative_path,
resource_directory,
resource_payload,
@@ -405,3 +409,236 @@ def test_data_resources_model_allows_duplicate_storage_object_reference() -> Non
assert "storage_object_id" not in cols, (
f"unexpected unique index {idx.name} on storage_object_id"
)
# ─── parent_path filtering (mirrors test_list_scripts_parent_path.py) ────────
def _resource_ctx(workspace_id: str = "W001") -> SimpleNamespace:
return SimpleNamespace(
request_id="test",
user=SimpleNamespace(user_id="U001"),
workspace=SimpleNamespace(workspace_id=workspace_id),
)
class TestBuildListResourcesDescendantPrefix:
"""Pure-function contract for the escaped object_key prefix. Unlike
the scripts helper there is NO user scope — data resources are
workspace-wide, so the prefix is just the normalized+escaped path."""
def test_empty_parent_path_returns_empty_prefix(self) -> None:
assert _build_list_resources_descendant_prefix("") == ""
def test_subdir_prefix_appends_trailing_slash(self) -> None:
assert _build_list_resources_descendant_prefix("foo/bar") == "foo/bar/"
def test_escapes_underscore(self) -> None:
assert _build_list_resources_descendant_prefix("foo_bar") == r"foo\_bar/"
def test_escapes_percent(self) -> None:
assert _build_list_resources_descendant_prefix("100%match") == r"100\%match/"
def test_normalizes_leading_trailing_slashes(self) -> None:
assert _build_list_resources_descendant_prefix("/foo/bar/") == "foo/bar/"
def test_rejects_traversal(self) -> None:
with pytest.raises(HTTPException) as exc:
_build_list_resources_descendant_prefix("foo/../bar")
assert exc.value.status_code == 422
# ─── layer 2: SQL contract (mock session + mysql dialect compile) ────────────
def _compile_sql(stmt) -> str:
return str(
stmt.compile(
dialect=mysql_dialect.dialect(),
compile_kwargs={"literal_binds": True},
)
)
class _ListResourcesMockResult:
def all(self):
return []
def _list_resources_capturing_session(captured_sql: list[str]) -> MagicMock:
mock_session = MagicMock()
mock_session.execute = AsyncMock(
side_effect=lambda stmt: (
captured_sql.append(_compile_sql(stmt)) or _ListResourcesMockResult()
)
)
return mock_session
async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper() -> None:
from backend.resources import list_resources
captured_sql: list[str] = []
mock_session = _list_resources_capturing_session(captured_sql)
await list_resources(
parent_path="foo/bar",
context=_resource_ctx(),
session=mock_session,
visibility=None,
keyword=None,
)
assert len(captured_sql) == 1
sql = captured_sql[0].lower()
# 中间 ``%`` 是 owner 段通配符(跨所有 owner),父路径本身按字面匹配。
assert "like 'w001/%%/foo/bar/%%'" in sql
assert "not like 'w001/%%/foo/bar/%%/%%'" in sql
async def test_list_resources_where_clause_escapes_underscore() -> None:
"""Regression: parent_path containing ``_`` MUST be escaped in the
compiled LIKE pattern, otherwise sibling-path leak (``fooXbar``) returns."""
from backend.resources import list_resources
captured_sql: list[str] = []
mock_session = _list_resources_capturing_session(captured_sql)
await list_resources(
parent_path="foo_bar",
context=_resource_ctx(),
session=mock_session,
visibility=None,
keyword=None,
)
sql = captured_sql[0]
sql_lower = sql.lower()
# SQLAlchemy doubles the escape char inside the SQL string literal, so
# the helper's ``foo\_bar`` renders as ``foo\\_bar`` in the SQL text.
assert r"like 'w001/%%/foo\\_bar/%%'" in sql_lower
assert r"not like 'w001/%%/foo\\_bar/%%/%%'" in sql_lower
# Both LIKE clauses declare ESCAPE '\\' (two in total).
assert sql.count("ESCAPE '\\\\'") == 2, sql
async def test_list_resources_without_parent_path_adds_no_like_clause() -> None:
"""Empty parent_path keeps the legacy workspace-wide behaviour — no
object_key LIKE filter at all."""
from backend.resources import list_resources
captured_sql: list[str] = []
mock_session = _list_resources_capturing_session(captured_sql)
await list_resources(
parent_path="",
context=_resource_ctx(),
session=mock_session,
visibility=None,
keyword=None,
)
sql = captured_sql[0].lower()
assert " like " not in sql
assert " not like " not in sql
# ─── layer 3: behavioral test on real LIKE execution (SQLite) ────────────────
@pytest.fixture
def sqlite_object_key_table():
"""SQLite in-memory stand-in for ``storage_objects.object_key`` rows.
Three-layer structure: direct children under ``data`` owned by two
different users (cross-owner), a deeper descendant, a sibling folder,
a root file, plus ``data_x`` and its ``dataXx`` / ``data2x`` decoys.
"""
engine = create_engine("sqlite:///:memory:")
metadata = MetaData()
table = Table(
"object_keys",
metadata,
Column("object_key", String(1024), nullable=False),
)
metadata.create_all(engine)
rows = [
"W001/U001/data/alpha.csv", # direct child (owner U001)
"W001/U002/data/beta.csv", # direct child (owner U002)
"W001/U001/data/deep/nested.csv", # deeper descendant
"W001/U001/database/gamma.csv", # sibling folder
"W001/U001/root.csv", # root file
"W001/U001/data_x/delta.csv", # target for parent_path=data_x
"W001/U001/dataXx/decoy.csv", # sibling decoy (unescaped match)
"W001/U001/data2x/decoy2.csv", # sibling decoy (unescaped match)
]
with engine.begin() as conn:
conn.execute(table.insert(), [{"object_key": key} for key in rows])
yield engine, table
engine.dispose()
def test_sqlite_direct_children_across_owners(sqlite_object_key_table) -> None:
"""parent_path='data' returns exactly the direct children under data,
across every owner, excluding deeper/sibling/root paths."""
engine, table = sqlite_object_key_table
prefix = _build_list_resources_descendant_prefix("data")
like = f"W001/%/{prefix}%"
not_like = f"W001/%/{prefix}%/%"
with engine.connect() as conn:
rows = conn.execute(
select(table.c.object_key).where(
table.c.object_key.like(like, escape="\\"),
~table.c.object_key.like(not_like, escape="\\"),
)
).fetchall()
matched = sorted(r[0] for r in rows)
assert matched == [
"W001/U001/data/alpha.csv",
"W001/U002/data/beta.csv",
], matched
def test_sqlite_escaped_underscore_does_not_match_sibling(
sqlite_object_key_table,
) -> None:
"""parent_path='data_x' must match only the literal data_x folder, not
the dataXx / data2x siblings an unescaped pattern would match."""
engine, table = sqlite_object_key_table
prefix = _build_list_resources_descendant_prefix("data_x")
like = f"W001/%/{prefix}%"
not_like = f"W001/%/{prefix}%/%"
with engine.connect() as conn:
rows = conn.execute(
select(table.c.object_key).where(
table.c.object_key.like(like, escape="\\"),
~table.c.object_key.like(not_like, escape="\\"),
)
).fetchall()
matched = sorted(r[0] for r in rows)
assert matched == ["W001/U001/data_x/delta.csv"], matched
def test_sqlite_parent_path_with_slash_direct_children(sqlite_object_key_table) -> None:
"""parent_path='data/deep' returns only data/deep/* — never data/*."""
engine, table = sqlite_object_key_table
prefix = _build_list_resources_descendant_prefix("data/deep")
like = f"W001/%/{prefix}%"
not_like = f"W001/%/{prefix}%/%"
with engine.connect() as conn:
rows = conn.execute(
select(table.c.object_key).where(
table.c.object_key.like(like, escape="\\"),
~table.c.object_key.like(not_like, escape="\\"),
)
).fetchall()
matched = sorted(r[0] for r in rows)
assert matched == ["W001/U001/data/deep/nested.csv"], matched
def test_sqlite_empty_parent_path_has_no_like_filter(sqlite_object_key_table) -> None:
"""Empty parent_path → no LIKE filter → the endpoint's base WHERE only
(workspace-wide active resources). Stand-in: every row is returned."""
engine, table = sqlite_object_key_table
with engine.connect() as conn:
rows = conn.execute(select(table.c.object_key)).fetchall()
matched = sorted(r[0] for r in rows)
assert len(matched) == 8, matched