Files
tao.chenandClaude b493907775 feat(scripts): 跨 owner 懒加载目录树 + 跨用户可见 workspace/public
修两个后端接口问题:
1) /api/v1/workspace-directories 返回为空,目录树结构消失
2) 同 workspace 内脚本/数据互相可见但默认排除 private

后端改动
--------
* list_scripts / list_resources / list_workspace_directories 新增
  owner_user_id 可选 query 参数;缺省 = 当前请求者本人(scope 到
  workspace/{me}/...),传值时 scope 到该 owner 的子树。前端根加载
  默认只见自己一级,其他成员以折叠分组呈现。
* visibility 过滤统一:非 admin 请求者只返回 owner==me 或
  visibility ∈ {workspace, public};admin 跳过。owner=me 含自己
  的 private,owner=other 只剩其 workspace/public,排除他人 private。
* create_workspace_directory 两个分支 visibility 默认 'public'
  (非 private),使跨 owner 目录树可见;响应新增 owner_user_id 字段。
* platform.list_members 鉴权从 system_admin_context 放宽为
  系统管理员或该 workspace 活跃成员(让普通用户也能渲染同
  workspace 成员名册,用于跨 owner 分组)。
* main.py 注册 platform 模块(随 list_members 改动补齐导入)。
* .env.example 同步 common/config.py 26 个字段。

前端改动
--------
* ScriptExplorer.memberScriptGroups 改由 members 列表播种分组,
  display_name 取 members.display_name;inferredDirectories 现在按
  owner_user_id 标记,统一跨 owner 目录渲染。删除脚本目录页头与
  树分组标题的工作副本数量角标。
* WorkspaceTree 新增 ownerUserId 透传到 store.toggleExpanded;
  仅"我"的分组 mount 时 auto-expand,他人分组默认折叠,展开才
  调 loadOwnerGroup / owner-scoped loadScripts / loadChildren。
* scriptWorkspaceStore 引入 namespaced cache key
  (ownerCacheKey = `${ownerUserId ?? me}:${path}`),loadedScriptPaths
  / loadedChildPaths / loadedOwnerGroups 全部按 owner 隔离;
  toggleExpanded 用 loadPath === undefined 区分 group 头与真实
  目录,修"他人子目录点击不触发接口"的 loadPath 前缀误判 bug。
* api.ts / AuthContext 透传 ownerUserId 给 listScripts /
  listResources / listWorkspaceDirectories。

文档
----
* API.md: §3.2 创建目录 visibility 默认 public + 响应加 owner_user_id;
  §3.3.1 GET directories 加 owner_user_id 参数 + 响应字段;
  §3.4 GET scripts 改写为 owner 作用域 + visibility 过滤语义;
  §五.1 GET data-resources 新增,同一套统一语义;
  §7 intro 例外 — GET members 对系统管理员或 workspace 活跃成员开放。
* DEVELOP.md: Code layout 重写以反映 backend api/services/clients/
  schemas 拆分 + schedule domain/scheduling/application/execution/
  infrastructure 拆分 + common 子包(auth/storage/backends);
  Configuration 系统补全 26 个 settings 字段;新增
  "Owner-scoping + visibility (cross-owner browsing)" 小节;
  Per-service dev 注释用 uv run 的源布局要求;Add a new DAG endpoint /
  storage bucket 路径改为 backend/src/backend/api/* 与 services/*。

测试
----
* test_list_scripts_parent_path.py /
  test_resources.py 补充 owner_user_id 参数化直接调用 + LIKE
  前缀断言(workspace/{owner}/... 前缀)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 19:26:53 +08:00

811 lines
28 KiB
Python

"""Unit tests for data-resource path derivation and helpers.
These tests do not need a database because they exercise pure helpers.
"""
from __future__ import annotations
import datetime
from types import SimpleNamespace
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.api.resources import (
_build_list_resources_descendant_prefix,
can_view,
compute_jupyter_relative_path,
resource_directory,
resource_payload,
)
from backend.services.storage import (
_object_key_tail,
_safe_file_name,
_safe_path_segment,
_strip_uniqueness_suffix,
)
_BIND_WS = "01WS0000000000000000000A"
_BIND_USER = "01USR0000000000000000000A"
class _ExecuteResult:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
class _BindSessionMock:
"""Mocked session for bind_resource.
``new_object_key`` is the StorageObjects row of the upload being bound;
``same_name_rows`` are (DataResources, StorageObjects) candidates already
in the workspace with the same resource_name.
"""
def __init__(
self,
new_object_key: str,
same_name_rows=(),
reused_resource=None,
usage_type: str = "data_resource",
):
self._new_object_key = new_object_key
self._same_name_rows = list(same_name_rows)
self._reused_resource = reused_resource
self._usage_type = usage_type
self._scalar_calls = 0
async def scalar(self, _stmt):
from sqlalchemy import TextClause
if isinstance(_stmt, TextClause):
return 1 # GET_LOCK 成功
self._scalar_calls += 1
# 1st scalar: UploadSessions lookup; 2nd: active 绑定行复用检查。
if self._scalar_calls == 1:
return SimpleNamespace(
upload_id="01UPL0000000000000000000B",
storage_object_id="01OBJ0000000000000000000B",
workspace_id=_BIND_WS,
user_id=_BIND_USER,
usage_type=self._usage_type,
)
if self._scalar_calls == 2 and self._reused_resource is not None:
return self._reused_resource
return None
async def get(self, _model, _pk):
return _make_storage_object(self._new_object_key)
async def execute(self, _stmt):
from sqlalchemy import TextClause
if isinstance(_stmt, TextClause):
return _ExecuteResult([]) # RELEASE_LOCK
# 模拟 SQL 的 owner 过滤与 self-exclusion:只返回属于当前用户且
# 不是当前 upload 已绑定对象的同名候选。
rows = [
(resource, storage_object)
for resource, storage_object in self._same_name_rows
if resource.owner_user_id == _BIND_USER
and resource.storage_object_id != "01OBJ0000000000000000000B"
]
return _ExecuteResult(rows)
def add(self, _obj):
pass
async def flush(self):
pass
async def refresh(self, _obj, attribute_names=None):
_obj.created_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
_obj.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
def _bind_context():
return SimpleNamespace(
request_id="01REQ0000000000000000000A",
user=SimpleNamespace(user_id=_BIND_USER),
workspace=SimpleNamespace(workspace_id=_BIND_WS),
)
def _bind_payload():
return SimpleNamespace(
resource_name="data.csv", description=None, visibility="private"
)
@pytest.mark.asyncio
async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None:
"""Same resource_name in the same directory raises 409."""
from backend.api.resources import bind_resource
existing_rows = [
(
_make_resource(_BIND_WS, _BIND_USER),
_make_storage_object(f"{_BIND_WS}/{_BIND_USER}/data.csv"),
)
]
session = _BindSessionMock(
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
same_name_rows=existing_rows,
)
with pytest.raises(Exception) as exc_info:
await bind_resource(
upload_id="01UPL0000000000000000000B",
payload=_bind_payload(),
request=MagicMock(),
context=_bind_context(),
session=session,
)
assert exc_info.value.status_code == 409
assert "already exists" in exc_info.value.detail
@pytest.mark.asyncio
async def test_bind_resource_allows_same_name_in_different_directory() -> None:
"""Same resource_name in a different directory binds successfully."""
from backend.api.resources import bind_resource
existing_rows = [
(
_make_resource(_BIND_WS, _BIND_USER),
_make_storage_object(f"{_BIND_WS}/{_BIND_USER}/data.csv"),
)
]
session = _BindSessionMock(
new_object_key=f"{_BIND_WS}/{_BIND_USER}/subdir/data.csv",
same_name_rows=existing_rows,
)
result = await bind_resource(
upload_id="01UPL0000000000000000000B",
payload=_bind_payload(),
request=MagicMock(),
context=_bind_context(),
session=session,
)
assert result["data"]["resource_name"] == "data.csv"
assert result["data"]["jupyter_accessible_path"] == "subdir/data.csv"
@pytest.mark.asyncio
async def test_bind_resource_allows_same_name_when_workspace_empty() -> None:
"""No same-name rows at all: bind succeeds (root directory)."""
from backend.api.resources import bind_resource
session = _BindSessionMock(
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
)
result = await bind_resource(
upload_id="01UPL0000000000000000000A",
payload=_bind_payload(),
request=MagicMock(),
context=_bind_context(),
session=session,
)
assert result["data"]["resource_name"] == "data.csv"
@pytest.mark.asyncio
async def test_bind_resource_allows_same_name_for_different_owner() -> None:
"""其他用户在同目录下的同名资源不阻塞当前用户的绑定。"""
from backend.api.resources import bind_resource
other_user = "01USR0000000000000000000B"
existing_rows = [
(
_make_resource(_BIND_WS, other_user),
_make_storage_object(f"{_BIND_WS}/{other_user}/data.csv"),
)
]
session = _BindSessionMock(
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
same_name_rows=existing_rows,
)
result = await bind_resource(
upload_id="01UPL0000000000000000000C",
payload=_bind_payload(),
request=MagicMock(),
context=_bind_context(),
session=session,
)
assert result["data"]["resource_name"] == "data.csv"
@pytest.mark.asyncio
async def test_bind_resource_allows_rebinding_same_storage_object() -> None:
"""重新绑定同一 upload_id 应走 idempotent 复用路径,不触发 409。"""
from backend.api.resources import bind_resource
new_object_key = f"{_BIND_WS}/{_BIND_USER}/data.csv"
existing_resource = _make_resource(_BIND_WS, _BIND_USER)
existing_resource.resource_name = "data.csv"
existing_resource.storage_object_id = "01OBJ0000000000000000000B"
existing_object = _make_storage_object(new_object_key)
existing_object.storage_object_id = "01OBJ0000000000000000000B"
same_name_rows = [(existing_resource, existing_object)]
session = _BindSessionMock(
new_object_key=new_object_key,
same_name_rows=same_name_rows,
reused_resource=existing_resource,
)
result = await bind_resource(
upload_id="01UPL0000000000000000000B",
payload=_bind_payload(),
request=MagicMock(),
context=_bind_context(),
session=session,
)
assert result["meta"]["reused"] is True
assert result["data"]["resource_name"] == "data.csv"
@pytest.mark.asyncio
async def test_bind_resource_rejects_non_data_resource_upload() -> None:
"""其他用途(如 working_copy)的 upload session 不能 bind 成数据资源。"""
from backend.api.resources import bind_resource
session = _BindSessionMock(
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
usage_type="working_copy",
)
with pytest.raises(Exception) as exc_info:
await bind_resource(
upload_id="01UPL0000000000000000000B",
payload=_bind_payload(),
request=MagicMock(),
context=_bind_context(),
session=session,
)
assert exc_info.value.status_code == 409
assert "not created for a data resource" in exc_info.value.detail
def test_safe_path_segment_cleans_special_characters():
assert _safe_path_segment("train") == "train"
assert _safe_path_segment("train v1") == "train_v1"
assert _safe_path_segment("../foo") == "foo"
assert _safe_path_segment("a/b") == "a_b"
assert _safe_path_segment("...") == "untitled"
def test_safe_file_name_rejects_traversal_and_hidden():
assert _safe_file_name("data.csv") == "data.csv"
with pytest.raises(Exception):
_safe_file_name("../data.csv")
with pytest.raises(Exception):
_safe_file_name(".hidden.csv")
def _make_resource(workspace_id: str, owner_user_id: str):
return SimpleNamespace(
resource_id="01RES0000000000000000000A",
workspace_id=workspace_id,
storage_object_id="01OBJ0000000000000000000A",
owner_user_id=owner_user_id,
resource_name="sample",
description=None,
visibility="workspace",
status="active",
created_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None),
updated_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None),
)
def _make_storage_object(object_key: str):
return SimpleNamespace(
storage_object_id="01OBJ0000000000000000000A",
object_key=object_key,
file_name="data.csv",
file_extension=".csv",
mime_type="text/csv",
size_bytes=42,
content_hash="a" * 64,
object_status="available",
)
def test_resource_payload_legacy_dot_resources():
ws = "01WS00000000000000000000A"
user = "01USR000000000000000000A"
payload = resource_payload(
_make_resource(ws, user),
_make_storage_object(f"{ws}/{user}/.resources/data.csv"),
)
assert payload["jupyter_accessible_path"] == ".resources/data.csv"
assert payload["absolute_path"].endswith(f"{ws}/{user}/.resources/data.csv")
# Default None when no Users join row is provided (bind_resource path).
assert payload["owner_display_name"] is None
def test_resource_payload_new_flat_path():
ws = "01WS00000000000000000000A"
user = "01USR000000000000000000A"
payload = resource_payload(
_make_resource(ws, user),
_make_storage_object(f"{ws}/{user}/data.csv"),
)
assert payload["jupyter_accessible_path"] == "data.csv"
assert payload["absolute_path"].endswith(f"{ws}/{user}/data.csv")
# Explicit owner_display_name is passed through to the payload.
payload = resource_payload(
_make_resource(ws, user),
_make_storage_object(f"{ws}/{user}/data.csv"),
owner_display_name="张三",
)
assert payload["owner_display_name"] == "张三"
def test_resource_payload_new_nested_path():
ws = "01WS00000000000000000000A"
user = "01USR000000000000000000A"
payload = resource_payload(
_make_resource(ws, user),
_make_storage_object(f"{ws}/{user}/train/v1/data.csv"),
)
assert payload["jupyter_accessible_path"] == "train/v1/data.csv"
assert payload["absolute_path"].endswith(f"{ws}/{user}/train/v1/data.csv")
assert payload["owner_display_name"] is None
def test_compute_jupyter_relative_path_for_legacy_and_new_paths():
# Legacy .resources path still resolves correctly.
assert compute_jupyter_relative_path("notebooks/exp.ipynb", ".resources/data.csv") == "../.resources/data.csv"
# New nested path resolves relative to the script directory.
assert compute_jupyter_relative_path("notebooks/exp.ipynb", "train/v1/data.csv") == "../train/v1/data.csv"
# Same-directory script.
assert compute_jupyter_relative_path("exp.ipynb", "data.csv") == "data.csv"
def test_resource_directory_parses_object_key():
ws = "01WS00000000000000000000A"
user = "01USR000000000000000000A"
# 根目录文件:目录为 ""。
assert resource_directory(f"{ws}/{user}/data.csv", ws, user) == ""
# 子目录 / 多级目录。
assert resource_directory(f"{ws}/{user}/sub/data.csv", ws, user) == "sub"
assert (
resource_directory(f"{ws}/{user}/train/v1/data.csv", ws, user)
== "train/v1"
)
# 旧版 .resources 布局:目录为 ".resources"。
assert (
resource_directory(f"{ws}/{user}/.resources/data.csv", ws, user)
== ".resources"
)
# 不匹配 ws/user 前缀的键按根目录处理。
assert resource_directory("other-bucket-key.csv", ws, user) == ""
def test_object_key_tail_and_strip_uniqueness_suffix():
ws = "01WS00000000000000000000A"
user = "01USR000000000000000000A"
# 前缀剥离。
assert _object_key_tail(f"{ws}/{user}/sub/data.csv", ws, user) == "sub/data.csv"
assert _object_key_tail("no-prefix.csv", ws, user) == "no-prefix.csv"
# 带唯一化后缀的 key 恢复为原始路径(26 位 ULID 后缀)。
suffix = "01arz3ndektsv4rrffq69g5fav" # 26 chars
assert (
_strip_uniqueness_suffix(f"sub/data-{suffix}.csv") == "sub/data.csv"
)
assert _strip_uniqueness_suffix(f"data-{suffix}.csv") == "data.csv"
assert _strip_uniqueness_suffix(f"data-{suffix}") == "data"
# 普通文件名不受影响(短后缀、无连字符、目录含连字符)。
assert _strip_uniqueness_suffix("sub/data.csv") == "sub/data.csv"
assert _strip_uniqueness_suffix("data-v1.csv") == "data-v1.csv"
assert _strip_uniqueness_suffix("my-dir/data.csv") == "my-dir/data.csv"
assert _strip_uniqueness_suffix("data") == "data"
def test_data_resources_model_allows_duplicate_storage_object_reference() -> None:
"""With uk_data_resources_object dropped, no unique index covers
storage_object_id, so multiple DataResources rows may reference the
same storage object.
"""
from common.db.models import DataResources
index_names = {idx.name for idx in DataResources.__table__.indexes}
assert "uk_data_resources_object" not in index_names
for idx in DataResources.__table__.indexes:
if idx.unique:
cols = {c.name for c in idx.columns}
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),
is_admin=False,
)
class TestCanViewVisibility:
"""同一 workspace 内:owner 永远可见自己的资源(含 private);
其他成员只见 visibility in {workspace, public} 的资源;
admin 全部可见。与 list_resources 的 SQL 谓词一致。"""
@staticmethod
def _viewer() -> SimpleNamespace:
return SimpleNamespace(
request_id="test",
user=SimpleNamespace(user_id="U002"), # not the owner
workspace=SimpleNamespace(workspace_id="W001"),
is_admin=False,
)
@staticmethod
def _owner() -> SimpleNamespace:
ctx = TestCanViewVisibility._viewer()
ctx.user = SimpleNamespace(user_id="U001") # the owner
return ctx
@staticmethod
def _admin() -> SimpleNamespace:
ctx = TestCanViewVisibility._viewer()
ctx.is_admin = True
return ctx
@staticmethod
def _resource(visibility: str) -> SimpleNamespace:
res = _make_resource("W001", "U001")
res.visibility = visibility
return res
def test_owner_can_view_own_private_resource(self) -> None:
assert (
can_view(
self._resource("private"),
self._owner(),
)
is True
)
def test_workspace_member_cannot_view_others_private_resource(self) -> None:
assert (
can_view(
self._resource("private"),
self._viewer(),
)
is False
)
def test_workspace_member_can_view_others_workspace_resource(self) -> None:
assert (
can_view(
self._resource("workspace"),
self._viewer(),
)
is True
)
def test_workspace_member_can_view_others_public_resource(self) -> None:
assert (
can_view(
self._resource("public"),
self._viewer(),
)
is True
)
def test_admin_can_view_others_private_resource(self) -> None:
assert (
can_view(
self._resource("private"),
self._admin(),
)
is True
)
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.api.resources import list_resources
captured_sql: list[str] = []
mock_session = _list_resources_capturing_session(captured_sql)
await list_resources(
parent_path="foo/bar",
owner_user_id=None,
context=_resource_ctx(),
session=mock_session,
visibility=None,
keyword=None,
)
assert len(captured_sql) == 1
sql = captured_sql[0].lower()
# Default (no owner_user_id) scopes to the requester's own object_key
# subtree: LIKE w001/u001/foo/bar/% (direct children), excluding deeper.
assert "like 'w001/u001/foo/bar/%%'" in sql
assert "not like 'w001/u001/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.api.resources import list_resources
captured_sql: list[str] = []
mock_session = _list_resources_capturing_session(captured_sql)
await list_resources(
parent_path="foo_bar",
owner_user_id=None,
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/u001/foo\\_bar/%%'" in sql_lower
assert r"not like 'w001/u001/foo\\_bar/%%/%%'" in sql_lower
# Both LIKE clauses declare ESCAPE '\\' (two in total).
assert sql.count("ESCAPE '\\\\'") == 2, sql
async def test_list_resources_empty_parent_path_adds_root_like_clause() -> None:
"""Empty parent_path still applies the directory filter (symmetric with
list_scripts): ``{ws_id}/{owner}/%`` AND NOT ``{ws_id}/{owner}/%/%`` so
the owner-scoped root view returns only direct children of the
requester's root, never nested descendants. Skipping the filter for
empty input used to surface nested resources at the root and visually
broke the directory tree.
"""
from backend.api.resources import list_resources
captured_sql: list[str] = []
mock_session = _list_resources_capturing_session(captured_sql)
await list_resources(
parent_path="",
owner_user_id=None,
context=_resource_ctx(),
session=mock_session,
visibility=None,
keyword=None,
)
sql = captured_sql[0].lower()
assert " like 'w001/u001/%%'" in sql
assert " not like 'w001/u001/%%/%%'" in sql
async def test_list_resources_owner_param_scopes_to_other_owner() -> None:
"""owner_user_id=<other> scopes object_key LIKE to that owner's subtree
so the tree can lazily fetch another member's data resources on group
expand. Non-admin visibility predicate is still applied, so the other
owner's private resources are excluded (workspace/public only).
"""
from backend.api.resources import list_resources
captured_sql: list[str] = []
mock_session = _list_resources_capturing_session(captured_sql)
await list_resources(
parent_path="",
owner_user_id="U002",
context=_resource_ctx(),
session=mock_session,
visibility=None,
keyword=None,
)
sql = captured_sql[0].lower()
assert " like 'w001/u002/%%'" in sql
assert " not like 'w001/u002/%%/%%'" in sql
# non-admin: visibility predicate still present (excludes U002 private)
assert "data_resources.owner_user_id = 'u001'" in sql
assert "data_resources.visibility in ('workspace', 'public')" in sql
async def test_list_resources_joins_users_for_display_name() -> None:
"""list_resources must OUTER JOIN users and SELECT users.display_name so
every resource carries owner_display_name (frontend displayName chain)."""
from backend.api.resources import list_resources
captured_sql: list[str] = []
mock_session = _list_resources_capturing_session(captured_sql)
await list_resources(
parent_path="",
owner_user_id=None,
context=_resource_ctx(),
session=mock_session,
visibility=None,
keyword=None,
)
sql = captured_sql[0]
sql_lower = sql.lower()
assert "outer join users" in sql_lower
assert "users.display_name" in sql_lower
# ─── 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_returns_root_level_across_owners(
sqlite_object_key_table,
) -> None:
"""Empty parent_path now applies the root filter (symmetric with
list_scripts): ``{ws_id}/%/%`` AND NOT ``{ws_id}/%/%/%`` returns only
direct children of every owner's root, excluding nested descendants.
Earlier 'no LIKE' behaviour used to surface every row in the
workspace at the root, which is exactly what made scripts and data
appear mutually visible and broke the tree.
"""
engine, table = sqlite_object_key_table
like = "W001/%/%"
not_like = "W001/%/%/%"
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)
# ``W001/U001/root.csv`` is the only 3-segment path (= direct child
# of the owner root); all 4+ segment paths (data/*, database/*) are
# excluded by the NOT LIKE clause. The other 4+ segment files would
# be returned when the user expands the corresponding subdirectory.
assert matched == ["W001/U001/root.csv"], matched