2026-07-21 15:28:55 +08:00
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
|
|
process.env.DATABASE_URL = 'pg-mem://';
|
|
|
|
|
|
process.env.INITIAL_ADMIN_PASSWORD = 'AdminTest123!';
|
|
|
|
|
|
process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-characters';
|
|
|
|
|
|
|
|
|
|
|
|
const { default: app } = await import('../api/app.js');
|
|
|
|
|
|
const { closeDatabase } = await import('../api/database.js');
|
|
|
|
|
|
const server = app.listen(0, '127.0.0.1');
|
|
|
|
|
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
|
|
|
|
|
const address = server.address();
|
|
|
|
|
|
if (!address || typeof address === 'string') throw new Error('测试服务启动失败');
|
|
|
|
|
|
const base = `http://127.0.0.1:${address.port}`;
|
|
|
|
|
|
|
|
|
|
|
|
async function request(path: string, init: RequestInit = {}, cookie?: string) {
|
|
|
|
|
|
const response = await fetch(`${base}${path}`, { ...init, headers: { ...(init.headers || {}), ...(cookie ? { Cookie: cookie } : {}) } });
|
|
|
|
|
|
const body = response.status === 204 ? null : await response.json();
|
|
|
|
|
|
return { response, body };
|
|
|
|
|
|
}
|
|
|
|
|
|
function expectStatus(actual: number, expected: number, label: string, body?: unknown) {
|
|
|
|
|
|
if (actual !== expected) throw new Error(`${label}: 期望 ${expected},实际 ${actual},响应 ${JSON.stringify(body)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const health = await request('/api/health'); expectStatus(health.response.status, 200, '健康检查');
|
|
|
|
|
|
if ((health.body as { database?: string }).database !== 'postgres') throw new Error('测试未运行在 PostgreSQL 查询层');
|
|
|
|
|
|
|
|
|
|
|
|
const login = await request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'AdminTest123!' }) });
|
|
|
|
|
|
expectStatus(login.response.status, 200, '平台管理员登录');
|
|
|
|
|
|
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
|
|
|
|
|
|
if (!adminCookie) throw new Error('登录未返回会话 Cookie');
|
|
|
|
|
|
|
|
|
|
|
|
const group = await request('/api/management/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '测试运营组', username: 'test_manager', display_name: '测试管理员', password: 'Manager123!' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(group.response.status, 201, '创建运营组');
|
|
|
|
|
|
const groupId = Number((group.body as { id: number }).id);
|
|
|
|
|
|
|
|
|
|
|
|
const duplicateGroupAdmin = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'second_manager', display_name: '林溪', password: 'Manager123!', role: 'group_admin' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(duplicateGroupAdmin.response.status, 409, '拒绝第二位组管理员', duplicateGroupAdmin.body);
|
|
|
|
|
|
const firstOperator = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'first_operator', display_name: '林溪', password: 'Operator123!', role: 'operator' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(firstOperator.response.status, 201, '创建第一位光影叙事', firstOperator.body);
|
|
|
|
|
|
const secondOperator = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'second_operator', display_name: '周宁', password: 'Operator123!', role: 'operator' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(secondOperator.response.status, 201, '创建第二位光影叙事', secondOperator.body);
|
|
|
|
|
|
const secondPlatformAdmin = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'second_admin', display_name: '平台管理员乙', password: 'AdminTest123!', role: 'platform_admin' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(secondPlatformAdmin.response.status, 201, '创建第二位平台管理员', secondPlatformAdmin.body);
|
|
|
|
|
|
const renameOperator = await request(`/api/management/users/${Number((firstOperator.body as { id: number }).id)}/name`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: '林清溪' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(renameOperator.response.status, 200, '修改光影叙事用户名', renameOperator.body);
|
|
|
|
|
|
const groupAccounts = await request(`/api/management/users?groupId=${groupId}`, {}, adminCookie);
|
|
|
|
|
|
expectStatus(groupAccounts.response.status, 200, '读取组内账号', groupAccounts.body);
|
|
|
|
|
|
const groupUsers = groupAccounts.body as Array<{ display_name: string; role: string }>;
|
|
|
|
|
|
if (groupUsers.filter((item) => item.role === 'group_admin').length !== 1 || groupUsers.filter((item) => item.role === 'operator').length !== 2) throw new Error('运营组账号数量规则未正确执行');
|
|
|
|
|
|
const allAccounts = await request('/api/management/users', {}, adminCookie);
|
|
|
|
|
|
expectStatus(allAccounts.response.status, 200, '读取平台账号', allAccounts.body);
|
|
|
|
|
|
if ((allAccounts.body as Array<{ role: string }>).filter((item) => item.role === 'platform_admin').length !== 2) throw new Error('多个平台管理员未正确创建');
|
|
|
|
|
|
const replaceAdmin = await request(`/api/management/groups/${groupId}/replace-admin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: Number((firstOperator.body as { id: number }).id), previous_action: 'demote' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(replaceAdmin.response.status, 200, '更换组管理员', replaceAdmin.body);
|
|
|
|
|
|
const replacedAccounts = await request(`/api/management/users?groupId=${groupId}`, {}, adminCookie);
|
|
|
|
|
|
const replacedUsers = replacedAccounts.body as Array<{ id:number;role:string;last_login_at:string|null }>;
|
|
|
|
|
|
if (replacedUsers.filter((item)=>item.role==='group_admin').length!==1 || replacedUsers.find((item)=>item.role==='group_admin')?.id!==Number((firstOperator.body as {id:number}).id)) throw new Error('组管理员更换未保持唯一身份');
|
|
|
|
|
|
const groupsAfterReplace = await request('/api/management/groups', {}, adminCookie);
|
|
|
|
|
|
expectStatus(groupsAfterReplace.response.status, 200, '读取运营组统计', groupsAfterReplace.body);
|
|
|
|
|
|
const groupStats=(groupsAfterReplace.body as Array<{id:number;user_count:number;operator_count:number;group_admin_name:string|null}>).find((item)=>item.id===groupId);
|
|
|
|
|
|
if(!groupStats||groupStats.user_count!==3||groupStats.operator_count!==2||!groupStats.group_admin_name)throw new Error('运营组统计不正确');
|
|
|
|
|
|
const renameGroup=await request(`/api/management/groups/${groupId}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'已更名运营组'})},adminCookie);
|
|
|
|
|
|
expectStatus(renameGroup.response.status,200,'修改运营组名称',renameGroup.body);
|
|
|
|
|
|
if((renameGroup.body as {name:string}).name!=='已更名运营组')throw new Error('运营组名称未正确更新');
|
|
|
|
|
|
const newAdminLogin=await request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'first_operator',password:'Operator123!'})});
|
|
|
|
|
|
expectStatus(newAdminLogin.response.status,200,'新组管理员登录',newAdminLogin.body);
|
2026-07-21 18:28:21 +08:00
|
|
|
|
const newAdminCookie=newAdminLogin.response.headers.get('set-cookie')?.split(';')[0];
|
|
|
|
|
|
if(!newAdminCookie)throw new Error('新组管理员登录未返回会话 Cookie');
|
2026-07-21 15:28:55 +08:00
|
|
|
|
const accountsAfterLogin=await request(`/api/management/users?groupId=${groupId}`,{},adminCookie);
|
|
|
|
|
|
if(!(accountsAfterLogin.body as Array<{username:string;last_login_at:string|null}>).find((item)=>item.username==='first_operator')?.last_login_at)throw new Error('最近登录时间未记录');
|
|
|
|
|
|
const adminLogs=await request('/api/management/audit-logs?userId=1',{},adminCookie);
|
|
|
|
|
|
expectStatus(adminLogs.response.status,200,'按账号筛选审计日志',adminLogs.body);
|
|
|
|
|
|
if(!(adminLogs.body as Array<{action:string}>).some((item)=>item.action==='group.admin_replace'))throw new Error('管理员更换审计日志缺失');
|
|
|
|
|
|
|
|
|
|
|
|
const project = await request('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'PostgreSQL 联调项目', slug: 'postgres-runtime-test', client_description: '运行时验证', groupId }) }, adminCookie);
|
|
|
|
|
|
expectStatus(project.response.status, 201, '创建项目', project.body);
|
|
|
|
|
|
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
|
|
|
|
|
|
const projectId = Number((project.body as { id: number }).id);
|
2026-07-21 18:28:21 +08:00
|
|
|
|
const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie);
|
|
|
|
|
|
expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body);
|
|
|
|
|
|
const otherProjectId=Number((otherProject.body as {id:number}).id);
|
2026-07-21 15:28:55 +08:00
|
|
|
|
|
|
|
|
|
|
const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(access.response.status, 200, '配置客户访问');
|
|
|
|
|
|
|
|
|
|
|
|
const collection = await request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '第一阶段', client_description: '验收阶段' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(collection.response.status, 201, '创建作品交付集');
|
2026-07-21 18:28:21 +08:00
|
|
|
|
const collectionId=Number((collection.body as {id:number}).id);
|
|
|
|
|
|
|
|
|
|
|
|
const projectKey=await request('/api/management/api-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'项目接入测试 Key',project_id:projectId})},newAdminCookie);
|
|
|
|
|
|
expectStatus(projectKey.response.status,201,'创建项目级 API Key',projectKey.body);
|
|
|
|
|
|
const projectKeyId=Number((projectKey.body as {item:{id:number}}).item.id);
|
|
|
|
|
|
const projectToken=(projectKey.body as {token:string}).token;
|
|
|
|
|
|
const bearerHeaders={Authorization:`Bearer ${projectToken}`};
|
|
|
|
|
|
const visibleProjects=await request('/api/projects',{headers:bearerHeaders});
|
|
|
|
|
|
expectStatus(visibleProjects.response.status,200,'项目级 Key 查询项目',visibleProjects.body);
|
|
|
|
|
|
if((visibleProjects.body as Array<{id:number}>).length!==1||Number((visibleProjects.body as Array<{id:number}>)[0].id)!==projectId)throw new Error('项目级 Key 未严格隔离到绑定项目');
|
|
|
|
|
|
const forbiddenProject=await request(`/api/projects/${otherProjectId}`,{headers:bearerHeaders});
|
|
|
|
|
|
expectStatus(forbiddenProject.response.status,403,'项目级 Key 拒绝访问其他项目',forbiddenProject.body);
|
|
|
|
|
|
const visibleCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
|
|
|
|
|
expectStatus(visibleCollections.response.status,200,'项目级 Key 查询作品交付集',visibleCollections.body);
|
|
|
|
|
|
if(!(visibleCollections.body as Array<{id:number}>).some((item)=>Number(item.id)===collectionId))throw new Error('项目级 Key 未返回目标作品交付集');
|
|
|
|
|
|
const createWorkBody={collectionId,externalId:'runtime-client-work-001',title:'接口作品 V1',description:'公开 URL 图片',tags:['API 测试'],images:['https://cdn.example.com/runtime-v1.jpg']};
|
|
|
|
|
|
const createdWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
|
|
|
|
|
|
expectStatus(createdWork.response.status,201,'JSON URL 创建作品',createdWork.body);
|
|
|
|
|
|
const workId=Number((createdWork.body as {id:number}).id);
|
|
|
|
|
|
const repeatedWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
|
|
|
|
|
|
expectStatus(repeatedWork.response.status,200,'externalId 幂等创建',repeatedWork.body);
|
|
|
|
|
|
if(Number((repeatedWork.body as {id:number}).id)!==workId||!(repeatedWork.body as {idempotent?:boolean}).idempotent)throw new Error('externalId 重复请求创建了不同作品');
|
|
|
|
|
|
const foundWork=await request(`/api/notes?collectionId=${collectionId}&externalId=runtime-client-work-001`,{headers:bearerHeaders});
|
|
|
|
|
|
expectStatus(foundWork.response.status,200,'externalId 查询作品',foundWork.body);
|
|
|
|
|
|
if((foundWork.body as Array<{id:number}>).length!==1||Number((foundWork.body as Array<{id:number}>)[0].id)!==workId)throw new Error('未能通过 externalId 找回作品');
|
|
|
|
|
|
const newVersion=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V2',description:'第二版',tags:['API 测试'],images:['https://cdn.example.com/runtime-v2.jpg']})});
|
|
|
|
|
|
expectStatus(newVersion.response.status,201,'JSON URL 创建新版本',newVersion.body);
|
|
|
|
|
|
if(Number((newVersion.body as {version_number:number}).version_number)!==2)throw new Error('作品版本号未递增');
|
|
|
|
|
|
const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
|
|
|
|
|
|
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
|
|
|
|
|
|
const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images;
|
|
|
|
|
|
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存');
|
|
|
|
|
|
const revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie);
|
|
|
|
|
|
expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key');
|
2026-07-21 15:28:55 +08:00
|
|
|
|
|
|
|
|
|
|
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
|
|
|
|
|
|
expectStatus(reviewLogin.response.status, 200, '客户登录');
|
|
|
|
|
|
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
|
|
|
|
|
|
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
|
|
|
|
|
|
expectStatus(reviewProject.response.status, 200, '客户项目读取');
|
|
|
|
|
|
|
|
|
|
|
|
const apiKey = await request('/api/management/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '运行时测试 Key' }) }, adminCookie);
|
|
|
|
|
|
expectStatus(apiKey.response.status, 201, '创建平台 API Key');
|
|
|
|
|
|
const keyId = Number((apiKey.body as { item: { id: number } }).item.id);
|
|
|
|
|
|
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
|
|
|
|
|
|
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
|
|
|
|
|
|
|
2026-07-21 18:28:21 +08:00
|
|
|
|
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、作品交付集、API Key 发现、externalId 幂等、JSON URL 作品与新版本\n');
|
2026-07-21 15:28:55 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
|
|
|
|
await closeDatabase();
|
|
|
|
|
|
}
|