feat(scripts/resources): align list_scripts visibility with list_resources
69a9a48 把 data resources 的可见性从 list 恒真改成
workspace-wide + visibility 过滤 + admin 短路,但 scripts 端
没动。两端不对称,导致:
* 非 admin 调 list_scripts 走 user_relative_path →
workspace/{当前用户ID}/...,永远拿不到别人的脚本
* count_scripts 同样 user-scoped,dashboard "全部脚本"
只统计自己
* list_resources 响应没带 owner_display_name,data-only
owner 的目录名回退到 userId.slice(-6),显示不友好
修复:
* list_scripts / count_scripts 改 workspace-wide(新建
_build_list_scripts_workspace_descendant_prefix helper;
旧 _build_list_scripts_descendant_prefix 保留标 deprecated
避免破坏其它调用方);非 admin 追加
or_(owner_user_id = me, visibility in {workspace, public})
与 list_resources 完全对称;admin 短路
* resource_payload 加 owner_display_name 字段(与
script_payload 对称);list_resources SELECT 加
Users.display_name + outerjoin
* 前端 ScriptExplorer displayName 回退链:scripts 的
owner_display_name → data resources 的 owner_display_name
→ 本人 user.display_name → userId.slice(-6) 占位
;ResourceItem 类型同步加 owner_display_name?: string|null
存储物理布局仍是 workspace/{user_id}/...,仅读取侧
listing/count 跨 owner。
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"""Unit tests for GET /api/v1/scripts/count endpoint.
|
||||
|
||||
Verifies the count endpoint returns the same scope as
|
||||
``list_scripts(parent_path="")``: workspace + active scripts whose
|
||||
``StorageObjects.relative_path`` lives under the user's subtree. This
|
||||
avoids under/over-reporting on the dashboard — the count is the size of
|
||||
the set list_scripts would return if it weren't lazy.
|
||||
Verifies the count endpoint matches the (workspace-wide) listing scope of
|
||||
``list_scripts(parent_path="")``: workspace + active scripts across every
|
||||
owner's ``StorageObjects.relative_path``, narrowed by visibility for
|
||||
non-admin (admin short-circuits). This avoids under/over-reporting on the
|
||||
dashboard — the count is the size of the set list_scripts would return if
|
||||
it weren't lazy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,13 +18,23 @@ import pytest
|
||||
from backend.scripts import count_scripts
|
||||
|
||||
|
||||
def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace:
|
||||
def _ctx(
|
||||
user_id: str = "U001",
|
||||
workspace_id: str = "W001",
|
||||
*,
|
||||
is_admin: bool = False,
|
||||
is_system_admin: bool = False,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
request_id="test",
|
||||
user=SimpleNamespace(user_id=user_id),
|
||||
workspace=SimpleNamespace(workspace_id=workspace_id),
|
||||
role=SimpleNamespace(role_code="admin"),
|
||||
is_system_admin=False,
|
||||
role=SimpleNamespace(role_code="admin" if is_admin else "developer"),
|
||||
is_system_admin=is_system_admin,
|
||||
# ``count_scripts`` now consults ``context.is_admin`` directly
|
||||
# (matching list_scripts / list_resources); SimpleNamespace needs
|
||||
# it as a plain attribute.
|
||||
is_admin=is_admin or is_system_admin,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,10 +68,13 @@ async def test_count_scripts_returns_scalar_int() -> None:
|
||||
# JOIN to StorageObjects so orphaned scripts (no joinable row) are
|
||||
# excluded — matches list_scripts INNER JOIN behaviour.
|
||||
assert "inner join storage_objects" in sql
|
||||
# Scope: workspace_id + active status + user subtree.
|
||||
# Scope: workspace_id + active status + workspace-wide prefix.
|
||||
assert "scripts.workspace_id" in sql
|
||||
assert "scripts.status" in sql
|
||||
assert "workspace/u001/%" in sql
|
||||
assert "like 'workspace/%%'" in sql
|
||||
# Non-admin (default) narrows by visibility.
|
||||
assert "scripts.owner_user_id = 'u001'" in sql
|
||||
assert "scripts.visibility in ('workspace', 'public')" in sql
|
||||
|
||||
|
||||
async def test_count_scripts_handles_null_result() -> None:
|
||||
@@ -72,10 +86,10 @@ async def test_count_scripts_handles_null_result() -> None:
|
||||
assert result["data"] == {"total": 0}
|
||||
|
||||
|
||||
async def test_count_scripts_uses_user_specific_subtree() -> None:
|
||||
"""Different users in the same workspace must see different totals —
|
||||
each user's count is bounded by their own ``workspace/{user_id}/``
|
||||
subtree, NOT the whole workspace."""
|
||||
async def test_count_scripts_workspace_wide_not_user_scoped() -> None:
|
||||
"""The prefix is workspace-wide (``workspace/%`` — no embedded user_id),
|
||||
so different users count the same physical tree; the only per-user
|
||||
difference is the non-admin visibility predicate (owner_user_id = me)."""
|
||||
captured = []
|
||||
|
||||
mock_session = MagicMock()
|
||||
@@ -84,14 +98,41 @@ async def test_count_scripts_uses_user_specific_subtree() -> None:
|
||||
)
|
||||
|
||||
await count_scripts(context=_ctx(user_id="alice"), session=mock_session)
|
||||
sql_alice = _compile(captured[-1])
|
||||
sql_alice = _compile(captured[-1]).lower()
|
||||
|
||||
await count_scripts(context=_ctx(user_id="bob"), session=mock_session)
|
||||
sql_bob = _compile(captured[-1])
|
||||
sql_bob = _compile(captured[-1]).lower()
|
||||
|
||||
assert "workspace/alice/%" in sql_alice
|
||||
assert "workspace/alice/%" not in sql_bob
|
||||
assert "workspace/bob/%" in sql_bob
|
||||
# Both count the same workspace-wide subtree.
|
||||
assert "like 'workspace/%%'" in sql_alice
|
||||
assert "like 'workspace/%%'" in sql_bob
|
||||
# Neither embeds the user_id in the path prefix.
|
||||
assert "workspace/alice/%" not in sql_alice
|
||||
assert "workspace/bob/%" not in sql_bob
|
||||
# Per-user narrowing happens via the visibility predicate.
|
||||
assert "scripts.owner_user_id = 'alice'" in sql_alice
|
||||
assert "scripts.owner_user_id = 'bob'" in sql_bob
|
||||
|
||||
|
||||
async def test_count_scripts_admin_skips_visibility_filter() -> None:
|
||||
"""Admin short-circuits the visibility predicate and counts every
|
||||
active script in the workspace (dashboard '全部脚本' / '工作副本')."""
|
||||
captured = []
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.scalar = AsyncMock(
|
||||
side_effect=lambda stmt: (captured.append(stmt), 42)[1]
|
||||
)
|
||||
|
||||
result = await count_scripts(
|
||||
context=_ctx(user_id="alice", is_admin=True), session=mock_session
|
||||
)
|
||||
assert result["data"] == {"total": 42}
|
||||
sql = _compile(captured[0]).lower()
|
||||
assert "like 'workspace/%%'" in sql
|
||||
# visibility / owner_user_id still appear in the SELECT projection, but
|
||||
# the visibility WHERE predicate must be absent for admins.
|
||||
assert "scripts.visibility in ('workspace', 'public')" not in sql
|
||||
|
||||
|
||||
async def test_count_scripts_route_declared_before_script_id_route() -> None:
|
||||
@@ -101,4 +142,4 @@ async def test_count_scripts_route_declared_before_script_id_route() -> None:
|
||||
from backend.scripts import count_scripts, get_script
|
||||
|
||||
assert callable(count_scripts)
|
||||
assert callable(get_script)
|
||||
assert callable(get_script)
|
||||
|
||||
Reference in New Issue
Block a user