""" 平台治理功能集成测试 —— 覆盖第 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 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 [] 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 [] 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