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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user