修两个后端接口问题:
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>
811 lines
28 KiB
Python
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
|