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
|
|
|
|
|
|
|
2026-08-10 11:41:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-10 11:41:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-10 11:41:16 +08:00
|
|
|
|
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:
|
|
|
|
|
|
"""构建 TestClient,patch 所有治理模块的 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
|