Files
YG_FT/backend/tests/test_governance.py

792 lines
34 KiB
Python
Raw Permalink Normal View History

2026-08-03 09:34:08 +08:00
"""
平台治理功能集成测试 覆盖第 1-4 周交付内容
测试策略
- 在导入 app 模块前 mock psycopg / psycopg_pool避免依赖真实数据库驱动
- 使用 FastAPI TestClient 对真实路由栈发起请求
- 通过 mock.get_platform_store 替换为内存 FakeStore
- 每周交付内容对应一组 test class方便分阶段验收
覆盖范围
1 登录当前用户用户列表权限码日志查询
2 租户项目项目成员资源 ACL
3 审批实例审批模板审计日志查询和导出
4 写操作审计审批拦截权限校验
"""
from __future__ import annotations
import json
import sys
import types
from contextlib import contextmanager
from typing import Any, Iterator
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# ============================================================
# 在导入 app 之前 mock psycopg / psycopg_pool
# ============================================================
_psycopg_mock = types.ModuleType("psycopg")
_psycopg_mock.PgConn = type("PgConn", (), {})
_psycopg_mock.PostgresConnectionPool = MagicMock()
_psycopg_mock.connection = MagicMock()
sys.modules.setdefault("psycopg", _psycopg_mock)
_psycopg_pool_mock = types.ModuleType("psycopg_pool")
_psycopg_pool_mock.ConnectionPool = MagicMock()
sys.modules.setdefault("psycopg_pool", _psycopg_pool_mock)
# 现在安全导入 app 模块
from app.api.v1.endpoints.platform import ok, fail # noqa: E402
from app.modules.tenant.router import router as tenant_router # noqa: E402
from app.modules.project.router import router as project_router # noqa: E402
from app.modules.approval.router import router as approval_router # noqa: E402
from app.modules.system.router import router as system_router # noqa: E402
from app.modules.retention.router import router as retention_router # noqa: E402
from app.modules.resource.router import router as resource_router # noqa: E402
from app.api.v1.endpoints.platform import router as platform_router # noqa: E402
PREFIX = "/modelTF"
ADMIN_TOKEN = "platform-token-u_admin"
OP_TOKEN = "platform-token-u_op"
# ============================================================
# FakePlatformStore —— 内存实现,模拟 PlatformStore 全部治理接口
# ============================================================
class FakePlatformStore:
"""平台治理测试专用内存 store确保测试不连接真实数据库。"""
def __init__(self) -> None:
self._users: list[dict[str, Any]] = [
{
"id": "u_admin",
"username": "admin",
"display_name": "Admin",
"role": "admin",
"status": "active",
"permissions": [
"dashboard", "fine-tune", "model-eval", "model-inference",
"model-manage", "dataset", "data-process", "data-convert",
"compute", "hardware", "logs", "user-settings",
],
"last_login": "2026-08-01T10:00:00Z",
"protected": True,
},
{
"id": "u_op",
"username": "operator",
"display_name": "Operator",
"role": "operator",
"status": "active",
"permissions": ["dashboard", "fine-tune"],
"last_login": "2026-08-01T11:00:00Z",
"protected": False,
},
]
self._tenants: dict[str, dict[str, Any]] = {}
self._projects: dict[str, dict[str, Any]] = {}
self._members: dict[str, list[dict[str, Any]]] = {}
self._acl: dict[str, list[dict[str, Any]]] = {}
self._audit_logs: list[dict[str, Any]] = []
self._approval_templates: dict[str, dict[str, Any]] = {}
self._approval_instances: dict[str, dict[str, Any]] = {}
self._retention_policies: dict[str, dict[str, Any]] = {}
self._models: list[dict[str, Any]] = []
self._datasets: list[dict[str, Any]] = []
self._tasks: list[dict[str, Any]] = []
self._compute_nodes: list[dict[str, Any]] = []
self._gpus: list[dict[str, Any]] = []
self._sessions: list[dict[str, Any]] = []
self._seq = 0
@contextmanager
def connect(self) -> Iterator[Any]:
class FakeConn:
def execute(self, *a, **kw):
return []
def commit(self):
pass
def rollback(self):
pass
def close(self):
pass
yield FakeConn()
# ---- helpers ----
def _next_id(self, prefix: str) -> str:
self._seq += 1
return f"{prefix}_{self._seq}"
# ==================== 第1周登录 / 用户 / 权限码 / 日志 ====================
def login(self, username: str, password: str) -> dict[str, Any] | None:
for u in self._users:
if u["username"] == username and u["status"] == "active":
if password in ("admin123", "operator123", "test123"):
return dict(u)
return None
def create_session(self, user_id: str) -> dict[str, Any]:
import secrets
sid = secrets.token_hex(16)
return {"session_id": sid, "user_id": user_id}
def finish_session(self, session_id: str) -> None:
pass
2026-08-03 09:34:08 +08:00
def users(self) -> list[dict[str, Any]]:
return [dict(u) for u in self._users]
def create_user(self, payload: dict[str, Any]) -> dict[str, Any]:
u = {"id": self._next_id("u"), "protected": False, **payload}
self._users.append(u)
return u
def update_user(self, user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
for u in self._users:
if u["id"] == user_id:
u.update(payload)
return u
raise KeyError(user_id)
def delete_user(self, user_id: str) -> None:
self._users = [u for u in self._users if u["id"] != user_id]
def roles(self) -> list[dict[str, Any]]:
return [
{"name": "admin", "display_name": "管理员"},
{"name": "operator", "display_name": "操作员"},
{"name": "viewer", "display_name": "访客"},
]
def log_files(self, date: str | None = None) -> list[dict[str, Any]]:
return [{"name": "backend-2026-08-01.log", "size": "1 KB", "date": "2026-08-01"}]
def log_content(self, file: str) -> dict[str, Any]:
return {"file": file, "content": "[INFO] test line", "size": "1 KB"}
def training_log_files(self) -> list[dict[str, Any]]:
return [{"task_id": "ft_001", "name": "ft_001.log", "size": "2 KB"}]
def training_log_content(self, file: str) -> dict[str, Any]:
return {"file": file, "content": "epoch 0 loss 1.0", "size": "2 KB"}
# ==================== 第2周租户 / 项目 / 成员 / ACL ====================
def tenants(self) -> list[dict[str, Any]]:
return list(self._tenants.values())
def tenant(self, tenant_id: str) -> dict[str, Any]:
if tenant_id not in self._tenants:
raise KeyError(tenant_id)
return dict(self._tenants[tenant_id])
def create_tenant(self, payload: dict[str, Any]) -> dict[str, Any]:
tid = self._next_id("tnt")
t = {"id": tid, "status": "active", "quota": "{}", "retention_policy_id": None,
"create_time": "2026-08-01T00:00:00Z", **payload}
self._tenants[tid] = t
return dict(t)
def update_tenant(self, tenant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
self._tenants[tenant_id].update(payload)
return dict(self._tenants[tenant_id])
def set_tenant_quota(self, tenant_id: str, quota: dict[str, Any]) -> dict[str, Any]:
self._tenants[tenant_id]["quota"] = json.dumps(quota)
return dict(self._tenants[tenant_id])
def set_tenant_retention(self, tenant_id: str, retention_policy_id: str | None) -> dict[str, Any]:
self._tenants[tenant_id]["retention_policy_id"] = retention_policy_id
return dict(self._tenants[tenant_id])
def projects(self, *, tenant_id: str = "default", status: str | None = None, keyword: str | None = None) -> list[dict[str, Any]]:
result = []
for p in self._projects.values():
if p.get("tenant_id") != tenant_id:
continue
if status and p.get("status") != status:
continue
if keyword and keyword.lower() not in p.get("name", "").lower():
continue
result.append(dict(p))
return result
def project(self, project_id: str) -> dict[str, Any]:
if project_id not in self._projects:
raise KeyError(project_id)
return dict(self._projects[project_id])
def create_project(self, payload: dict[str, Any]) -> dict[str, Any]:
pid = self._next_id("prj")
p = {"id": pid, "status": "active", "quota": "{}", "create_time": "2026-08-01T00:00:00Z", **payload}
self._projects[pid] = p
self._members[pid] = []
return dict(p)
def update_project(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
self._projects[project_id].update(payload)
return dict(self._projects[project_id])
def archive_project(self, project_id: str) -> dict[str, Any]:
self._projects[project_id]["status"] = "archived"
return dict(self._projects[project_id])
def delete_project(self, project_id: str) -> None:
self._projects.pop(project_id, None)
self._members.pop(project_id, None)
def project_members(self, project_id: str) -> list[dict[str, Any]]:
return [dict(m) for m in self._members.get(project_id, [])]
def add_project_member(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
m = {"joined_at": "2026-08-01T00:00:00Z", **payload}
self._members.setdefault(project_id, []).append(m)
return m
def update_project_member_role(self, project_id: str, user_id: str, role: str) -> dict[str, Any]:
for m in self._members.get(project_id, []):
if m["user_id"] == user_id:
m["role"] = role
return m
raise KeyError(user_id)
def remove_project_member(self, project_id: str, user_id: str) -> None:
self._members[project_id] = [m for m in self._members.get(project_id, []) if m["user_id"] != user_id]
# ---- ACL ----
def get_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]:
key = f"{resource_type}:{resource_id}"
return [dict(a) for a in self._acl.get(key, [])]
def set_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
key = f"{resource_type}:{resource_id}"
self._acl[key] = [dict(e) for e in entries]
return self.get_acl(resource_type, resource_id)
def resource_acl(self, resource_type: str, resource_id: str) -> list[dict[str, Any]]:
rows = self.get_acl(resource_type, resource_id)
grouped: dict[str, dict[str, Any]] = {}
for r in rows:
k = f"{r.get('principal_type')}:{r.get('principal_id')}"
bucket = grouped.setdefault(k, {
"subject_type": r.get("principal_type"),
"subject_id": r.get("principal_id"),
"permissions": [],
})
perm = r.get("permission")
if perm and perm not in bucket["permissions"]:
bucket["permissions"].append(perm)
return list(grouped.values())
def set_resource_acl(self, resource_type: str, resource_id: str, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
flat: list[dict[str, Any]] = []
for e in entries:
for perm in e.get("permissions") or []:
flat.append({
"principal_type": e.get("subject_type"),
"principal_id": e.get("subject_id"),
"permission": perm,
})
self.set_acl(resource_type, resource_id, flat)
return self.resource_acl(resource_type, resource_id)
# ==================== 第3周审批 / 审计 / 留存 ====================
def approval_templates(self) -> list[dict[str, Any]]:
return list(self._approval_templates.values())
def create_approval_template(self, payload: dict[str, Any]) -> dict[str, Any]:
tid = payload.get("id") or self._next_id("tpl")
t = {"id": tid, "steps": [], "create_time": "2026-08-01T00:00:00Z", **payload}
self._approval_templates[tid] = t
return dict(t)
def approval_instances(self, *, status: str | None = None) -> list[dict[str, Any]]:
result = []
for i in self._approval_instances.values():
if status and i.get("status") != status:
continue
result.append(dict(i))
return result
def approval_instance(self, instance_id: str) -> dict[str, Any]:
if instance_id not in self._approval_instances:
raise KeyError(instance_id)
return dict(self._approval_instances[instance_id])
def create_approval_instance(self, payload: dict[str, Any]) -> dict[str, Any]:
iid = self._next_id("appr")
inst = {
"id": iid,
"status": "pending",
"current_step": 0,
"steps": [],
"create_time": "2026-08-01T00:00:00Z",
**payload,
}
self._approval_instances[iid] = inst
return dict(inst)
def decide_approval_step(self, instance_id: str, step_index: int, *, approver_id: str, approved: bool, comment: str | None = None) -> dict[str, Any]:
inst = self._approval_instances[instance_id]
inst["status"] = "approved" if approved else "rejected"
inst["current_step"] = step_index + 1
return dict(inst)
def audit_logs(self, **kw) -> dict[str, Any]:
items = [dict(l) for l in self._audit_logs]
for filter_key in ("tenant_id", "project_id", "actor_id", "action", "target_type"):
val = kw.get(filter_key)
if val:
items = [l for l in items if l.get(filter_key) == val]
limit = kw.get("limit", 50)
offset = kw.get("offset", 0)
total = len(items)
items = items[offset:offset + limit]
return {"items": items, "total": total}
def record_audit(self, **kw) -> None:
log = {"id": self._next_id("log"), "time": "2026-08-01T12:00:00Z", **kw}
self._audit_logs.append(log)
# ---- 留存策略 ----
def retention_policies(self) -> list[dict[str, Any]]:
return list(self._retention_policies.values())
def retention_policy(self, policy_id: str) -> dict[str, Any]:
if policy_id not in self._retention_policies:
raise KeyError(policy_id)
return dict(self._retention_policies[policy_id])
def create_retention_policy(self, payload: dict[str, Any]) -> dict[str, Any]:
pid = payload.get("id") or self._next_id("rpol")
p = {"id": pid, "status": "active", "create_time": "2026-08-01T00:00:00Z", **payload}
self._retention_policies[pid] = p
return dict(p)
def update_retention_policy(self, policy_id: str, payload: dict[str, Any]) -> dict[str, Any]:
self._retention_policies[policy_id].update(payload)
return dict(self._retention_policies[policy_id])
def delete_retention_policy(self, policy_id: str) -> None:
self._retention_policies.pop(policy_id, None)
# ---- dashboard & other stubs ----
def login_duration_rank(self, limit: int = 8, days: int = 30) -> list[dict[str, Any]]:
return [{"user": "admin", "role": "admin", "duration": 10.0}]
def models(self) -> list[dict[str, Any]]:
return self._models
def datasets(self) -> list[dict[str, Any]]:
return self._datasets
def tasks(self) -> list[dict[str, Any]]:
return self._tasks
def eval_tasks(self) -> list[dict[str, Any]]:
return []
2026-08-03 09:34:08 +08:00
def compute_nodes(self) -> list[dict[str, Any]]:
return self._compute_nodes
def gpus(self) -> list[dict[str, Any]]:
return self._gpus
def compare_tasks(self) -> list[dict[str, Any]]:
return []
def trained_models(self) -> list[dict[str, Any]]:
return []
2026-08-03 09:34:08 +08:00
def system_info(self) -> dict[str, Any]:
return {"cpu": {}, "memory": {}}
# ============================================================
# 测试 fixtures
# ============================================================
@pytest.fixture(scope="module")
def fake_store() -> FakePlatformStore:
return FakePlatformStore()
def _build_client(store: FakePlatformStore) -> TestClient:
"""构建 TestClientpatch 所有治理模块的 get_platform_store。"""
app = FastAPI()
app.include_router(platform_router, prefix=PREFIX)
app.include_router(system_router, prefix=PREFIX)
app.include_router(tenant_router, prefix=PREFIX)
app.include_router(project_router, prefix=PREFIX)
app.include_router(approval_router, prefix=PREFIX)
app.include_router(retention_router, prefix=PREFIX)
app.include_router(resource_router, prefix=PREFIX)
patches = [
patch("app.db.platform_store.get_platform_store", return_value=store),
patch("app.core.auth.get_platform_store", return_value=store),
patch("app.api.v1.endpoints.platform.get_platform_store", return_value=store),
patch("app.modules.system.router.get_platform_store", return_value=store),
patch("app.modules.tenant.router.get_platform_store", return_value=store),
patch("app.modules.project.router.get_platform_store", return_value=store),
patch("app.modules.approval.router.get_platform_store", return_value=store),
patch("app.modules.retention.router.get_platform_store", return_value=store),
patch("app.modules.resource.router.get_platform_store", return_value=store),
]
for p in patches:
p.start()
client = TestClient(app, raise_server_exceptions=False)
client._fake_store = store # type: ignore[attr-defined]
return client
@pytest.fixture(scope="module")
def client(fake_store: FakePlatformStore) -> TestClient:
c = _build_client(fake_store)
yield c
def _admin_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {ADMIN_TOKEN}"}
def _op_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {OP_TOKEN}"}
# ============================================================
# 第 1 周测试:登录、当前用户、用户列表、权限码、日志查询
# ============================================================
class TestWeek1AuthUserPermissionsLogs:
"""第 1 周:登录、当前用户、用户列表、权限码、日志查询接口。"""
def test_login_success(self, client: TestClient):
resp = client.post(f"{PREFIX}/login", json={"username": "admin", "password": "admin123"})
assert resp.status_code == 200
data = resp.json()["data"]
assert data["token"] == ADMIN_TOKEN
assert data["user"]["username"] == "admin"
def test_login_invalid(self, client: TestClient):
resp = client.post(f"{PREFIX}/login", json={"username": "admin", "password": "wrong"})
assert resp.status_code == 401
def test_me_with_valid_token(self, client: TestClient):
resp = client.get(f"{PREFIX}/me", headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["username"] == "admin"
def test_me_without_token(self, client: TestClient):
resp = client.get(f"{PREFIX}/me")
assert resp.status_code == 401
def test_users_list(self, client: TestClient):
resp = client.get(f"{PREFIX}/users", headers=_admin_headers())
assert resp.status_code == 200
users = resp.json()["data"]
assert len(users) >= 2
assert any(u["username"] == "admin" for u in users)
def test_create_user(self, client: TestClient):
resp = client.post(
f"{PREFIX}/users",
json={"username": "tester", "display_name": "Tester", "role": "viewer", "password": "test123"},
headers=_admin_headers(),
)
assert resp.status_code == 200
assert resp.json()["data"]["username"] == "tester"
def test_permission_codes(self, client: TestClient):
resp = client.get(f"{PREFIX}/system/permissions/codes")
assert resp.status_code == 200
codes = resp.json()["data"]["codes"]
assert "dashboard" in codes
assert "user-settings" in codes
def test_permissions_overview(self, client: TestClient):
resp = client.get(f"{PREFIX}/system/permissions")
assert resp.status_code == 200
data = resp.json()["data"]
assert "codes" in data
assert "roles" in data
def test_log_files(self, client: TestClient):
resp = client.get(f"{PREFIX}/log-files", headers=_admin_headers())
assert resp.status_code == 200
files = resp.json()["data"]
assert len(files) >= 1
def test_log_content(self, client: TestClient):
resp = client.get(f"{PREFIX}/log-content", params={"file": "backend.log"}, headers=_admin_headers())
assert resp.status_code == 200
assert "content" in resp.json()["data"]
def test_training_log_files(self, client: TestClient):
resp = client.get(f"{PREFIX}/training-log-files", headers=_admin_headers())
assert resp.status_code == 200
assert len(resp.json()["data"]) >= 1
def test_training_log_content(self, client: TestClient):
resp = client.get(f"{PREFIX}/training-log-content", params={"file": "ft_001.log"}, headers=_admin_headers())
assert resp.status_code == 200
assert "content" in resp.json()["data"]
# ============================================================
# 第 2 周测试:租户、项目、项目成员、资源 ACL
# ============================================================
class TestWeek2TenantProjectACL:
"""第 2 周:租户、项目、项目成员、资源 ACL。"""
def test_tenant_crud(self, client: TestClient):
# 创建
resp = client.post(f"{PREFIX}/tenants", json={"name": "Tenant-A", "code": "ta"}, headers=_admin_headers())
assert resp.status_code == 200
tid = resp.json()["data"]["id"]
# 查列表
resp = client.get(f"{PREFIX}/tenants", headers=_admin_headers())
assert resp.status_code == 200
assert any(t["id"] == tid for t in resp.json()["data"])
# 查详情
resp = client.get(f"{PREFIX}/tenants/{tid}", headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["name"] == "Tenant-A"
# 更新
resp = client.put(f"{PREFIX}/tenants/{tid}", json={"name": "Tenant-A2"}, headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["name"] == "Tenant-A2"
def test_tenant_quota(self, client: TestClient):
resp = client.post(f"{PREFIX}/tenants", json={"name": "Q-Tenant", "code": "qt"}, headers=_admin_headers())
tid = resp.json()["data"]["id"]
resp = client.put(f"{PREFIX}/tenants/{tid}/quota", json={"quota": {"gpu": 4}}, headers=_admin_headers())
assert resp.status_code == 200
def test_tenant_retention(self, client: TestClient):
resp = client.post(f"{PREFIX}/tenants", json={"name": "R-Tenant", "code": "rt"}, headers=_admin_headers())
tid = resp.json()["data"]["id"]
resp = client.put(f"{PREFIX}/tenants/{tid}/retention-policy", json={"retention_policy_id": "rpol_1"}, headers=_admin_headers())
assert resp.status_code == 200
def test_project_crud(self, client: TestClient):
# 创建项目
resp = client.post(f"{PREFIX}/projects", json={"name": "Proj-1", "code": "p1", "tenant_id": "default"}, headers=_admin_headers())
assert resp.status_code == 200
pid = resp.json()["data"]["id"]
# 查列表
resp = client.get(f"{PREFIX}/projects", params={"tenant_id": "default"}, headers=_admin_headers())
assert resp.status_code == 200
assert any(p["id"] == pid for p in resp.json()["data"])
# 查详情
resp = client.get(f"{PREFIX}/projects/{pid}", headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["name"] == "Proj-1"
# 更新
resp = client.put(f"{PREFIX}/projects/{pid}", json={"description": "updated"}, headers=_admin_headers())
assert resp.status_code == 200
# 归档
resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["status"] == "archived"
def test_project_members(self, client: TestClient):
resp = client.post(f"{PREFIX}/projects", json={"name": "Proj-M", "code": "pm", "tenant_id": "default"}, headers=_admin_headers())
pid = resp.json()["data"]["id"]
# 加成员
resp = client.post(f"{PREFIX}/projects/{pid}/members", json={"user_id": "u_op", "role": "developer"}, headers=_admin_headers())
assert resp.status_code == 200
# 列成员
resp = client.get(f"{PREFIX}/projects/{pid}/members", headers=_admin_headers())
assert resp.status_code == 200
assert len(resp.json()["data"]) >= 1
# 改角色
resp = client.put(f"{PREFIX}/projects/{pid}/members/u_op", json={"role": "maintainer"}, headers=_admin_headers())
assert resp.status_code == 200
# 删成员
resp = client.delete(f"{PREFIX}/projects/{pid}/members/u_op", headers=_admin_headers())
assert resp.status_code == 200
def test_resource_acl(self, client: TestClient):
# 设置 ACL
resp = client.put(
f"{PREFIX}/resources/model/m001/acl",
json={"entries": [{"subject_type": "user", "subject_id": "u_op", "permissions": ["read", "write"]}]},
headers=_admin_headers(),
)
assert resp.status_code == 200
result = resp.json()["data"]
assert len(result) == 1
assert set(result[0]["permissions"]) == {"read", "write"}
# 查询 ACL
resp = client.get(f"{PREFIX}/resources/model/m001/acl", headers=_admin_headers())
assert resp.status_code == 200
assert len(resp.json()["data"]) == 1
# ============================================================
# 第 3 周测试:审批实例、审批模板、审计日志查询和导出
# ============================================================
class TestWeek3ApprovalAudit:
"""第 3 周:审批实例、审批模板、审计日志查询和导出。"""
def test_approval_template_crud(self, client: TestClient):
# 创建模板
resp = client.post(f"{PREFIX}/approvals/templates", json={"name": "delete-approval", "steps": [{"approver_id": "u_admin", "status": "pending"}]}, headers=_admin_headers())
assert resp.status_code == 200
tpl_id = resp.json()["data"]["id"]
# 查列表
resp = client.get(f"{PREFIX}/approvals/templates", headers=_admin_headers())
assert resp.status_code == 200
assert any(t["id"] == tpl_id for t in resp.json()["data"])
def test_approval_instance_flow(self, client: TestClient):
# 创建审批实例
resp = client.post(f"{PREFIX}/approvals", json={
"resource_type": "dataset", "resource_id": "ds_001",
"applicant_id": "u_op",
}, headers=_admin_headers())
assert resp.status_code == 200
iid = resp.json()["data"]["id"]
# 查详情
resp = client.get(f"{PREFIX}/approvals/{iid}", headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["status"] == "pending"
# 审批决策
resp = client.post(f"{PREFIX}/approvals/{iid}/steps/0/decision", json={
"approver_id": "u_admin", "approved": True, "comment": "ok",
}, headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["status"] == "approved"
def test_approval_instance_reject(self, client: TestClient):
resp = client.post(f"{PREFIX}/approvals", json={
"resource_type": "model", "resource_id": "m_002",
"applicant_id": "u_op",
}, headers=_admin_headers())
iid = resp.json()["data"]["id"]
resp = client.post(f"{PREFIX}/approvals/{iid}/steps/0/decision", json={
"approver_id": "u_admin", "approved": False, "comment": "no",
}, headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["status"] == "rejected"
def test_approval_missing_field(self, client: TestClient):
resp = client.post(f"{PREFIX}/approvals", json={"resource_type": "dataset"}, headers=_admin_headers())
assert resp.status_code == 400
def test_audit_logs_query(self, client: TestClient):
# 通过 API 写操作触发审计
client.post(f"{PREFIX}/tenants", json={"name": "Audit-Tenant", "code": "at"}, headers=_admin_headers())
# 查询
resp = client.get(f"{PREFIX}/system/audit-logs", params={"limit": 50}, headers=_admin_headers())
assert resp.status_code == 200
data = resp.json()["data"]
assert "items" in data
assert "total" in data
assert data["total"] >= 1
def test_audit_logs_filter_by_action(self, client: TestClient):
resp = client.get(f"{PREFIX}/system/audit-logs", params={"action": "tenant.create"}, headers=_admin_headers())
assert resp.status_code == 200
items = resp.json()["data"]["items"]
assert all(i.get("action") == "tenant.create" for i in items)
def test_audit_logs_export_csv(self, client: TestClient):
resp = client.get(f"{PREFIX}/system/audit-logs/export", headers=_admin_headers())
assert resp.status_code == 200
assert "text/csv" in resp.headers.get("content-type", "")
# CSV 首行是表头
lines = resp.text.strip().split("\n")
assert "time" in lines[0]
# ============================================================
# 第 4 周测试:写操作审计、审批拦截、权限校验
# ============================================================
class TestWeek4AuditInterceptPermission:
"""第 4 周:写操作审计、审批拦截、权限校验。"""
def test_write_operation_produces_audit(self, client: TestClient, fake_store: FakePlatformStore):
# 清空审计日志便于断言
fake_store._audit_logs.clear()
# 创建租户 → 应产生 tenant.create 审计
client.post(f"{PREFIX}/tenants", json={"name": "W-Tenant", "code": "wt"}, headers=_admin_headers())
assert any(l["action"] == "tenant.create" for l in fake_store._audit_logs)
# 创建项目 → 应产生 project.create 审计
client.post(f"{PREFIX}/projects", json={"name": "W-Proj", "code": "wp", "tenant_id": "default"}, headers=_admin_headers())
assert any(l["action"] == "project.create" for l in fake_store._audit_logs)
# 设置 ACL → 应产生 resource.acl.set 审计
client.put(f"{PREFIX}/resources/model/w001/acl", json={"entries": []}, headers=_admin_headers())
assert any(l["action"] == "resource.acl.set" for l in fake_store._audit_logs)
def test_approval_intercept_on_project_archive(self, client: TestClient, fake_store: FakePlatformStore):
# 创建项目
resp = client.post(f"{PREFIX}/projects", json={"name": "I-Proj", "code": "ip", "tenant_id": "default"}, headers=_admin_headers())
pid = resp.json()["data"]["id"]
# 无待审批 → 可归档
resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers())
assert resp.status_code == 200
def test_approval_intercept_blocks_when_pending(self, client: TestClient, fake_store: FakePlatformStore):
# 创建项目
resp = client.post(f"{PREFIX}/projects", json={"name": "B-Proj", "code": "bp", "tenant_id": "default"}, headers=_admin_headers())
pid = resp.json()["data"]["id"]
# 注入一条待审批实例
fake_store.create_approval_instance({
"resource_type": "project",
"resource_id": pid,
"applicant_id": "u_op",
})
# 有待审批 → 归档应被拒绝
resp = client.post(f"{PREFIX}/projects/{pid}/archive", headers=_admin_headers())
assert resp.status_code == 409
def test_retention_policy_crud_with_audit(self, client: TestClient, fake_store: FakePlatformStore):
fake_store._audit_logs.clear()
# 创建
resp = client.post(f"{PREFIX}/retention-policies", json={"name": "30d-keep", "scope": "tenant"}, headers=_admin_headers())
assert resp.status_code == 200
rpid = resp.json()["data"]["id"]
assert any(l["action"] == "retention.create" for l in fake_store._audit_logs)
# 查列表
resp = client.get(f"{PREFIX}/retention-policies", headers=_admin_headers())
assert resp.status_code == 200
assert any(p["id"] == rpid for p in resp.json()["data"])
# 更新
resp = client.put(f"{PREFIX}/retention-policies/{rpid}", json={"status": "inactive"}, headers=_admin_headers())
assert resp.status_code == 200
assert resp.json()["data"]["status"] == "inactive"
# 删除
resp = client.delete(f"{PREFIX}/retention-policies/{rpid}", headers=_admin_headers())
assert resp.status_code == 200
def test_login_duration_rank_in_dashboard(self, client: TestClient):
resp = client.get(f"{PREFIX}/dashboard/stats", headers=_admin_headers())
assert resp.status_code == 200
data = resp.json()["data"]
assert "login_duration_rank" in data
assert "recent_login_users" in data
assert "service_status" in data
assert "training_7d" in data