- Add libs/api/admin with admin API endpoints: sync models, workspace credit, billing alert check - Add workspace_alert_config model and alert service - Add Session::no_op() for background tasks without user context - Add admin/ Next.js admin panel (AI models, billing, workspaces, audit) - Start billing alert background task every 30 minutes
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
import { test, expect, request } from "@playwright/test";
|
||
|
||
const BASE_URL = "http://localhost:3001";
|
||
const ADMIN_USER = process.env.ADMIN_TEST_USERNAME || "admin";
|
||
const ADMIN_PASS = process.env.ADMIN_TEST_PASSWORD || "admin123";
|
||
|
||
/**
|
||
* 创建已认证的 API context(登录获取 session cookie)
|
||
*/
|
||
async function createAuthContext() {
|
||
const ctx = await request.newContext({ baseURL: BASE_URL });
|
||
const loginRes = await ctx.post("/api/auth/login", {
|
||
data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||
});
|
||
if (!loginRes.ok()) {
|
||
throw new Error(`Login failed: ${loginRes.status()} ${await loginRes.text()}`);
|
||
}
|
||
return ctx;
|
||
}
|
||
|
||
test.describe("Admin 用户管理 API", () => {
|
||
let ctx: Awaited<ReturnType<typeof request.newContext>>;
|
||
|
||
test.beforeAll(async () => {
|
||
ctx = await createAuthContext();
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
await ctx.dispose();
|
||
});
|
||
|
||
test("GET /api/users 返回分页用户列表", async () => {
|
||
const res = await ctx.get("/api/users");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.users)).toBe(true);
|
||
expect(typeof data.total).toBe("number");
|
||
});
|
||
|
||
test("GET /api/users 支持分页参数", async () => {
|
||
const res = await ctx.get("/api/users?page=1&pageSize=5");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(data.users.length).toBeLessThanOrEqual(5);
|
||
});
|
||
|
||
test("GET /api/users 支持搜索", async () => {
|
||
const res = await ctx.get("/api/users?search=admin");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.users)).toBe(true);
|
||
if (data.users.length > 0) {
|
||
const names = data.users.map((u: { username: string }) => u.username);
|
||
names.forEach((name: string) => {
|
||
expect(name.toLowerCase()).toContain("admin");
|
||
});
|
||
}
|
||
});
|
||
|
||
test("POST /api/users 创建并删除用户", async () => {
|
||
const randomUser = `testuser_${Date.now()}`;
|
||
const createRes = await ctx.post("/api/users", {
|
||
data: { username: randomUser, password: "TestPass123!", roleIds: [] },
|
||
});
|
||
expect(createRes.status(), await createRes.text()).toBeLessThanOrEqual(201);
|
||
const created = await createRes.json();
|
||
if (created.user?.id) {
|
||
await ctx.delete(`/api/users/${created.user.id}`);
|
||
}
|
||
});
|
||
|
||
test("POST /api/users 缺少密码返回错误", async () => {
|
||
const randomUser = `testuser_${Date.now()}`;
|
||
const res = await ctx.post("/api/users", {
|
||
data: { username: randomUser },
|
||
});
|
||
expect(res.status()).toBeGreaterThanOrEqual(400);
|
||
});
|
||
|
||
test("DELETE /api/users/[id] 删除用户", async () => {
|
||
// 先创建用户
|
||
const randomUser = `deluser_${Date.now()}`;
|
||
const createRes = await ctx.post("/api/users", {
|
||
data: { username: randomUser, password: "TestPass123!", roleIds: [] },
|
||
});
|
||
const created = await createRes.json();
|
||
if (!created.user?.id) {
|
||
// 无权限,跳过
|
||
return;
|
||
}
|
||
const deleteRes = await ctx.delete(`/api/users/${created.user.id}`);
|
||
expect([200, 204]).toContain(deleteRes.status());
|
||
});
|
||
|
||
test("角色列表 API", async () => {
|
||
const res = await ctx.get("/api/roles");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.roles)).toBe(true);
|
||
});
|
||
|
||
test("权限列表 API", async () => {
|
||
const res = await ctx.get("/api/permissions");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.permissions)).toBe(true);
|
||
});
|
||
|
||
test("审计日志 API", async () => {
|
||
const res = await ctx.get("/api/logs");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.logs)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/sessions 返回 Admin 在线会话", async () => {
|
||
const res = await ctx.get("/api/sessions");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.sessions)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/roles/[id] 返回角色详情(含权限)", async () => {
|
||
// 先获取角色列表
|
||
const listRes = await ctx.get("/api/roles");
|
||
const listData = await listRes.json();
|
||
if (!listData.roles?.length) return;
|
||
const roleId = listData.roles[0].id;
|
||
const res = await ctx.get(`/api/roles/${roleId}`);
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(data).toHaveProperty("id");
|
||
expect(data).toHaveProperty("name");
|
||
});
|
||
|
||
test("POST /api/roles 创建并删除角色", async () => {
|
||
const randomRole = `testrole_${Date.now()}`;
|
||
const createRes = await ctx.post("/api/roles", {
|
||
data: { name: randomRole, description: "Test role" },
|
||
});
|
||
expect(createRes.status(), await createRes.text()).toBeLessThanOrEqual(201);
|
||
const created = await createRes.json();
|
||
if (created.id) {
|
||
const deleteRes = await ctx.delete(`/api/roles/${created.id}`);
|
||
expect([200, 204]).toContain(deleteRes.status());
|
||
}
|
||
});
|
||
});
|
||
|
||
test.describe("平台数据 API", () => {
|
||
let ctx: Awaited<ReturnType<typeof request.newContext>>;
|
||
|
||
test.beforeAll(async () => {
|
||
ctx = await createAuthContext();
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
await ctx.dispose();
|
||
});
|
||
|
||
test("GET /api/platform/stats 返回平台统计", async () => {
|
||
const res = await ctx.get("/api/platform/stats");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(data).toHaveProperty("stats");
|
||
expect(data.stats).toHaveProperty("userCount");
|
||
expect(data.stats).toHaveProperty("workspaceCount");
|
||
});
|
||
|
||
test("GET /api/platform/users 返回用户列表", async () => {
|
||
const res = await ctx.get("/api/platform/users");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.users)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/platform/workspaces 返回 workspace 列表", async () => {
|
||
const res = await ctx.get("/api/platform/workspaces");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.workspaces) || Array.isArray(data)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/platform/rooms 返回房间列表", async () => {
|
||
const res = await ctx.get("/api/platform/rooms");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.rooms) || Array.isArray(data)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/platform/repos 返回仓库列表", async () => {
|
||
const res = await ctx.get("/api/platform/repos");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.repos) || Array.isArray(data)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/platform/activity-stats 返回活动统计", async () => {
|
||
const res = await ctx.get("/api/platform/activity-stats");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(data).toHaveProperty("dau");
|
||
expect(data).toHaveProperty("mau");
|
||
});
|
||
|
||
test("GET /api/platform/audit-logs 返回审计日志", async () => {
|
||
const res = await ctx.get("/api/platform/audit-logs");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.logs) || Array.isArray(data)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/platform/sessions 返回平台会话", async () => {
|
||
const res = await ctx.get("/api/platform/sessions");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.sessions) || Array.isArray(data)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/admin/projects 返回项目列表", async () => {
|
||
const res = await ctx.get("/api/admin/projects");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.projects) || Array.isArray(data)).toBe(true);
|
||
expect(typeof data.total).toBe("number");
|
||
});
|
||
|
||
test("GET /api/admin/projects 支持分页参数", async () => {
|
||
const res = await ctx.get("/api/admin/projects?page=1&pageSize=5");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(data.projects.length).toBeLessThanOrEqual(5);
|
||
});
|
||
|
||
test("GET /api/admin/projects 支持搜索", async () => {
|
||
const res = await ctx.get("/api/admin/projects?search=test");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.projects)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/platform/ai 返回 AI Provider/Model/定价", async () => {
|
||
const res = await ctx.get("/api/platform/ai");
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(Array.isArray(data.providers)).toBe(true);
|
||
expect(Array.isArray(data.models)).toBe(true);
|
||
expect(Array.isArray(data.pricing)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/platform/workspaces/[id] 返回 Workspace 详情", async () => {
|
||
// 先获取一个 workspace ID
|
||
const listRes = await ctx.get("/api/platform/workspaces?pageSize=1");
|
||
const listData = await listRes.json();
|
||
if (!listData.workspaces?.length) return;
|
||
const wsId = listData.workspaces[0].id;
|
||
const res = await ctx.get(`/api/platform/workspaces/${wsId}`);
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(data).toHaveProperty("workspace");
|
||
expect(data).toHaveProperty("members");
|
||
expect(data).toHaveProperty("projects");
|
||
expect(data).toHaveProperty("billingHistory");
|
||
});
|
||
|
||
test("POST /api/platform/ai/sync 同步 AI 模型(需要 Rust 后端配置)", async () => {
|
||
const rustUrl = process.env.RUST_BACKEND_URL;
|
||
const apiKey = process.env.ADMIN_API_SHARED_KEY;
|
||
if (!rustUrl || !apiKey) return;
|
||
const res = await ctx.post("/api/platform/ai/sync");
|
||
expect([200, 500]).toContain(res.status());
|
||
});
|
||
|
||
test("POST /api/platform/alerts/check 检查告警(需要 Rust 后端配置)", async () => {
|
||
const rustUrl = process.env.RUST_BACKEND_URL;
|
||
const apiKey = process.env.ADMIN_API_SHARED_KEY;
|
||
if (!rustUrl || !apiKey) return;
|
||
const res = await ctx.post("/api/platform/alerts/check");
|
||
expect([200, 500]).toContain(res.status());
|
||
});
|
||
|
||
test("GET /api/platform/workspaces/[id]/alert-config 获取/保存告警配置", async () => {
|
||
const listRes = await ctx.get("/api/platform/workspaces?pageSize=1");
|
||
const listData = await listRes.json();
|
||
if (!listData.workspaces?.length) return;
|
||
const wsId = listData.workspaces[0].id;
|
||
// GET
|
||
const getRes = await ctx.get(`/api/platform/workspaces/${wsId}/alert-config`);
|
||
expect([200, 404]).toContain(getRes.status());
|
||
});
|
||
|
||
test("GET /api/platform/rooms/[id]/messages 返回房间消息", async () => {
|
||
const listRes = await ctx.get("/api/platform/rooms?pageSize=1");
|
||
const listData = await listRes.json();
|
||
if (!listData.rooms?.length) return;
|
||
const roomId = listData.rooms[0].id;
|
||
const res = await ctx.get(`/api/platform/rooms/${roomId}/messages`);
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(data).toHaveProperty("messages");
|
||
expect(Array.isArray(data.messages)).toBe(true);
|
||
});
|
||
|
||
test("GET /api/admin/projects/[id] 返回项目详情", async () => {
|
||
const listRes = await ctx.get("/api/admin/projects?pageSize=1");
|
||
const listData = await listRes.json();
|
||
if (!listData.projects?.length) return;
|
||
const projId = listData.projects[0].id;
|
||
const res = await ctx.get(`/api/admin/projects/${projId}`);
|
||
expect(res.status()).toBe(200);
|
||
const data = await res.json();
|
||
expect(data).toHaveProperty("project");
|
||
expect(data).toHaveProperty("members");
|
||
});
|
||
|
||
test("GET /api/admin/projects/[id]/billing 返回项目账单信息", async () => {
|
||
const listRes = await ctx.get("/api/admin/projects?pageSize=1");
|
||
const listData = await listRes.json();
|
||
if (!listData.projects?.length) return;
|
||
const projId = String(listData.projects[0].id);
|
||
const res = await ctx.get(`/api/admin/projects/${projId}/billing`);
|
||
// 200 = project found, 404 = project not found (billing table has no entry)
|
||
expect([200, 404]).toContain(res.status());
|
||
});
|
||
|
||
test("POST /api/admin/projects/[id]/billing 充值(需要项目 ID)", async () => {
|
||
const listRes = await ctx.get("/api/admin/projects?pageSize=1");
|
||
const listData = await listRes.json();
|
||
if (!listData.projects?.length) return;
|
||
const projId = String(listData.projects[0].id);
|
||
const res = await ctx.post(`/api/admin/projects/${projId}/billing`, {
|
||
data: { amount: 0.01, description: "Test credit" },
|
||
});
|
||
// 200 = ok, 404 = project not found, 500 = server error
|
||
expect([200, 404, 500]).toContain(res.status());
|
||
});
|
||
});
|
||
|
||
test.describe("中间件权限控制", () => {
|
||
test("未登录访问受保护 API 返回 401", async () => {
|
||
const freshCtx = await request.newContext({ baseURL: BASE_URL });
|
||
try {
|
||
const res = await freshCtx.get("/api/users");
|
||
expect(res.status()).toBe(401);
|
||
} finally {
|
||
await freshCtx.dispose();
|
||
}
|
||
});
|
||
|
||
test("无效 API Token 返回 401", async ({ request: req }) => {
|
||
const res = await req.get(`${BASE_URL}/api/users`, {
|
||
headers: { Authorization: "Bearer invalid_token_123" },
|
||
});
|
||
expect(res.status()).toBe(401);
|
||
});
|
||
});
|