Files
model-platform/backend/tests/test_count_scripts.py
tao.chen 8cf4b53dd4 fix(scripts): workspace-wide parent_path listing + private visibility on reads
1. 同 workspace 互相可见(排除 private):
   - list_scripts / count_scripts 已 workspace-wide + visibility 过滤,但
     单条读取(get/content/latest-version/versions)不校验 visibility,非
     owner 猜 id 即可读他人 private 脚本。新增 script_can_view(与 data
     resources 的 can_view 对称)并在 get_script_row / latest_version 强制,
     private 对非 owner 返回 404。

2. parent_path 为空默认拉根路径文件:
   - 物理存储为 workspace/{user_id}/...,根 prefix 原来是 workspace/ +
     NOT LIKE workspace/%/%,所有文件都在两层被整体排除,list_scripts("")
     恒空。改为 workspace/%/,配合 LIKE workspace/%/% AND NOT LIKE
     workspace/%/%/% 返回各 owner 根级文件。

3. 非空 parent_path 跨 owner 查询:
   - 原来 workspace/foo/ 永远匹配不到 workspace/{uid}/foo/...,子目录
     懒加载返回空,其他用户目录点击无内容。改为 workspace/%/foo/(owner
     段通配,与 list_resources 一致),_ / % 仍按字面转义。

测试:更新前缀契约断言,新增 SQLite 行为测试(跨 owner 根/子目录、转义)
与 script_can_view / get_script 权限测试,133 passed。
2026-08-21 17:20:05 +08:00

148 lines
5.4 KiB
Python

"""Unit tests for GET /api/v1/scripts/count endpoint.
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
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.api.scripts import count_scripts
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" 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,
)
def _compile(stmt) -> str:
from sqlalchemy.dialects import mysql as mysql_dialect
return str(
stmt.compile(
dialect=mysql_dialect.dialect(),
compile_kwargs={"literal_binds": True},
)
)
async def test_count_scripts_returns_scalar_int() -> None:
captured = []
mock_session = MagicMock()
mock_session.scalar = AsyncMock(
side_effect=lambda stmt: (captured.append(stmt), 7)[1]
)
result = await count_scripts(context=_ctx(), session=mock_session)
assert result["data"] == {"total": 7}
assert result["meta"] == {}
assert result["request_id"] == "test"
# Exactly one COUNT(*) query issued.
assert len(captured) == 1
stmt = captured[0]
sql = _compile(stmt).lower()
# 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 + workspace-wide prefix
# (owner segment wildcarded: workspace/%/%).
assert "scripts.workspace_id" in sql
assert "scripts.status" 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:
"""MySQL COUNT(*) on empty result returns 0, not NULL — but defensively
coerce NULL to 0 to keep the response shape consistent."""
mock_session = MagicMock()
mock_session.scalar = AsyncMock(return_value=None)
result = await count_scripts(context=_ctx(), session=mock_session)
assert result["data"] == {"total": 0}
async def test_count_scripts_workspace_wide_not_user_scoped() -> None:
"""The prefix is workspace-wide (``workspace/%/%`` — owner segment
wildcarded, 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()
mock_session.scalar = AsyncMock(
side_effect=lambda stmt: (captured.append(stmt), 3)[1]
)
await count_scripts(context=_ctx(user_id="alice"), session=mock_session)
sql_alice = _compile(captured[-1]).lower()
await count_scripts(context=_ctx(user_id="bob"), session=mock_session)
sql_bob = _compile(captured[-1]).lower()
# 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:
"""Static check: the `/api/v1/scripts/count` route MUST be declared in
scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's
declaration-order matching will interpret `count` as a script_id."""
from backend.api.scripts import count_scripts, get_script
assert callable(count_scripts)
assert callable(get_script)