Add audit log UI and backend API seeded E2E
- Add Auth Context route role guards so doctors cannot directly enter template management, user management, or audit logs. - Add Audit Logs page, sidebar entry, frontend audit API client, and API client test. - Add backend audit log query endpoint with super/admin visibility rules and query filtering. - Extend PostgreSQL integration tests to cover audit log query permissions. - Move Playwright E2E away from localStorage seed data to real backend API login and seed helpers. - Add E2E coverage for route guards and audit log visibility. - Run Playwright backend on port 3100 and proxy Vite API requests there to avoid local port conflicts. - Make server:dev use the compiled NestJS server path, avoiding tsx parameter-property injection issues. - Update README, AGENTS, feature, testing, security, deployment, progress, API, backendization, and auth/user module docs.
This commit is contained in:
21
e2e/audit-and-route-guards.spec.ts
Normal file
21
e2e/audit-and-route-guards.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { createReportByApi, loginByApi, uniqueId } from './helpers';
|
||||
|
||||
test('route guards block doctors from admin pages and super users can view audit logs', async ({ page }) => {
|
||||
await loginByApi(page, '0001');
|
||||
await page.goto('/user-manage');
|
||||
await page.waitForURL('**/dashboard');
|
||||
await expect(page.getByRole('heading', { name: '工作台概览' })).toBeVisible();
|
||||
|
||||
const title = `审计验证报告 ${uniqueId('audit')}`;
|
||||
await createReportByApi(page.request, {
|
||||
title,
|
||||
content: `<p>${title}</p>`,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await loginByApi(page, 'admin');
|
||||
await page.goto('/audit-logs');
|
||||
await expect(page.getByRole('heading', { name: '审计日志' })).toBeVisible();
|
||||
await expect(page.locator('tbody').getByText('完成报告').first()).toBeVisible();
|
||||
});
|
||||
148
e2e/helpers.ts
148
e2e/helpers.ts
@@ -1,77 +1,83 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
||||
|
||||
export const baseUsers = [
|
||||
{
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
role: 'super',
|
||||
name: '超级管理员',
|
||||
department: 'admin',
|
||||
status: 'active',
|
||||
visibleTemplates: ['dept_surgery'],
|
||||
manageableTemplates: ['dept_surgery'],
|
||||
},
|
||||
{
|
||||
username: 'manager',
|
||||
password: '123456',
|
||||
role: 'admin',
|
||||
name: '管理员',
|
||||
department: '外科',
|
||||
status: 'active',
|
||||
visibleTemplates: ['dept_surgery'],
|
||||
manageableTemplates: ['dept_surgery'],
|
||||
},
|
||||
{
|
||||
username: '0001',
|
||||
password: '123456',
|
||||
role: 'user',
|
||||
name: '张医生',
|
||||
department: '外科',
|
||||
status: 'active',
|
||||
visibleTemplates: ['dept_surgery'],
|
||||
manageableTemplates: [],
|
||||
},
|
||||
{
|
||||
username: '0002',
|
||||
password: '123456',
|
||||
role: 'user',
|
||||
name: '李医生',
|
||||
department: '内科',
|
||||
status: 'active',
|
||||
visibleTemplates: ['dept_internal'],
|
||||
manageableTemplates: [],
|
||||
},
|
||||
];
|
||||
export const uniqueId = (prefix: string) =>
|
||||
`${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
export const baseTemplates = [
|
||||
{
|
||||
id: 'dept_surgery',
|
||||
name: '外科部门模板',
|
||||
desc: 'E2E 外科模板',
|
||||
content: '<p><span class="field-value" data-bind="patientName" contenteditable="true"></span></p><div class="ai-region" data-ai-id="手术步骤"><div class="ai-content"><p>默认内容</p></div></div>',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
author: 'admin',
|
||||
scope: 'department',
|
||||
department: '外科',
|
||||
},
|
||||
{
|
||||
id: 'dept_internal',
|
||||
name: '内科部门模板',
|
||||
desc: 'E2E 内科模板',
|
||||
content: '<p>内科模板</p>',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
author: 'admin',
|
||||
scope: 'department',
|
||||
department: '内科',
|
||||
},
|
||||
];
|
||||
const toFrontendUser = (user: any) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role === 'doctor' ? 'user' : user.role,
|
||||
name: user.name,
|
||||
departmentId: user.departmentId,
|
||||
department: user.departmentName,
|
||||
status: user.status,
|
||||
signature: user.signature,
|
||||
signatureFileId: user.signatureFileId,
|
||||
visibleTemplates: [],
|
||||
manageableTemplates: [],
|
||||
});
|
||||
|
||||
export const seedLocalStorage = async (page: Page, data: Record<string, unknown>) => {
|
||||
await page.addInitScript((seed) => {
|
||||
export const apiRequest = async <T>(
|
||||
request: APIRequestContext,
|
||||
method: 'get' | 'post' | 'patch' | 'delete',
|
||||
path: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
const response = await request[method](path, data ? { data } : undefined);
|
||||
const text = await response.text();
|
||||
expect(response.ok(), `${method.toUpperCase()} ${path} failed: ${text}`).toBe(true);
|
||||
return text ? (JSON.parse(text).data as T) : (null as T);
|
||||
};
|
||||
|
||||
export const loginByApi = async (page: Page, username: string, password = '123456') => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
for (const [key, value] of Object.entries(seed)) {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
}, data);
|
||||
});
|
||||
await page.request.post('/api/auth/logout').catch(() => undefined);
|
||||
const data = await apiRequest<{ user: any }>(page.request, 'post', '/api/auth/login', { username, password });
|
||||
await page.evaluate((user) => {
|
||||
window.localStorage.setItem('currentUser', JSON.stringify(user));
|
||||
}, toFrontendUser(data.user));
|
||||
return data.user;
|
||||
};
|
||||
|
||||
export const createUserByApi = (
|
||||
request: APIRequestContext,
|
||||
body: {
|
||||
username: string;
|
||||
name: string;
|
||||
role: 'admin' | 'user';
|
||||
department?: string;
|
||||
departmentId?: string;
|
||||
visibleTemplates?: string[];
|
||||
manageableTemplates?: string[];
|
||||
},
|
||||
) =>
|
||||
apiRequest<{ user: any }>(request, 'post', '/api/users', {
|
||||
password: '123456',
|
||||
status: 'active',
|
||||
...body,
|
||||
}).then((data) => data.user);
|
||||
|
||||
export const createDepartmentByApi = (request: APIRequestContext, name: string, code: string) =>
|
||||
apiRequest<{ department: any }>(request, 'post', '/api/departments', { name, code }).then((data) => data.department);
|
||||
|
||||
export const createReportByApi = (
|
||||
request: APIRequestContext,
|
||||
body: {
|
||||
title: string;
|
||||
patientName?: string;
|
||||
hospitalId?: string;
|
||||
content?: string;
|
||||
status?: 'draft' | 'completed';
|
||||
},
|
||||
) =>
|
||||
apiRequest<{ report: any }>(request, 'post', '/api/reports', {
|
||||
patientName: 'E2E患者',
|
||||
hospitalId: uniqueId('H'),
|
||||
content: '<p>E2E报告内容</p>',
|
||||
status: 'completed',
|
||||
...body,
|
||||
}).then((data) => data.report);
|
||||
|
||||
@@ -1,37 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('default quick login enters dashboard', async ({ page }) => {
|
||||
await page.route('**/api/auth/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'UNAUTHORIZED', message: '未登录' } }),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/auth/login', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
user: {
|
||||
id: 'u-admin',
|
||||
username: 'admin',
|
||||
role: 'super',
|
||||
name: '超级管理员',
|
||||
tenantId: 'tenant-demo',
|
||||
departmentId: 'dept-admin',
|
||||
departmentName: 'admin',
|
||||
status: 'active',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
await page.getByText('admin / 123456').click();
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { baseTemplates, baseUsers, seedLocalStorage } from './helpers';
|
||||
import { apiRequest, loginByApi, uniqueId } from './helpers';
|
||||
|
||||
test('doctor can save current report as a personal template visible only to self', async ({ page }) => {
|
||||
await seedLocalStorage(page, {
|
||||
users: baseUsers,
|
||||
templates: baseTemplates,
|
||||
currentUser: baseUsers[2],
|
||||
reports: [],
|
||||
});
|
||||
await loginByApi(page, '0001');
|
||||
const templateName = `我的测试模板 ${uniqueId('tpl')}`;
|
||||
|
||||
page.on('dialog', async (dialog) => {
|
||||
if (dialog.type() === 'prompt') {
|
||||
await dialog.accept('我的测试模板');
|
||||
await dialog.accept(templateName);
|
||||
return;
|
||||
}
|
||||
await dialog.accept();
|
||||
@@ -22,11 +18,9 @@ test('doctor can save current report as a personal template visible only to self
|
||||
await page.getByRole('button', { name: '保存为我的模板' }).click();
|
||||
|
||||
await expect.poll(async () => {
|
||||
return page.evaluate(() => {
|
||||
const templates = JSON.parse(window.localStorage.getItem('templates') || '[]');
|
||||
return templates.some((template: any) => template.name === '我的测试模板' && template.scope === 'personal' && template.ownerUser === '0001');
|
||||
});
|
||||
const data = await apiRequest<{ items: any[] }>(page.request, 'get', '/api/templates?access=use');
|
||||
return data.items.some((template) => template.name === templateName && template.scope === 'personal' && template.ownerUser === '0001');
|
||||
}).toBe(true);
|
||||
|
||||
await expect(page.locator('option:not([disabled])', { hasText: '我的测试模板' })).toHaveCount(1);
|
||||
await expect(page.locator('option:not([disabled])', { hasText: templateName })).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -1,82 +1,60 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { baseTemplates, baseUsers, seedLocalStorage } from './helpers';
|
||||
|
||||
const reports = [
|
||||
{
|
||||
id: 'RPT_SURGERY_SELF',
|
||||
title: '外科本人报告',
|
||||
patientName: '患者甲',
|
||||
hospitalId: 'H001',
|
||||
department: '外科',
|
||||
content: '<p>外科本人报告</p>',
|
||||
author: '0001',
|
||||
authorName: '张医生',
|
||||
createdAt: '2026-05-01',
|
||||
status: 'completed',
|
||||
revision: 1,
|
||||
},
|
||||
{
|
||||
id: 'RPT_SURGERY_OTHER',
|
||||
title: '外科他人报告',
|
||||
patientName: '患者乙',
|
||||
hospitalId: 'H002',
|
||||
department: '外科',
|
||||
content: '<p>外科他人报告</p>',
|
||||
author: '0003',
|
||||
authorName: '王医生',
|
||||
createdAt: '2026-05-01',
|
||||
status: 'completed',
|
||||
revision: 1,
|
||||
},
|
||||
{
|
||||
id: 'RPT_INTERNAL',
|
||||
title: '内科报告',
|
||||
patientName: '患者丙',
|
||||
hospitalId: 'H003',
|
||||
department: '内科',
|
||||
content: '<p>内科报告</p>',
|
||||
author: '0002',
|
||||
authorName: '李医生',
|
||||
createdAt: '2026-05-01',
|
||||
status: 'completed',
|
||||
revision: 1,
|
||||
},
|
||||
];
|
||||
import {
|
||||
createDepartmentByApi,
|
||||
createReportByApi,
|
||||
createUserByApi,
|
||||
loginByApi,
|
||||
uniqueId,
|
||||
} from './helpers';
|
||||
|
||||
test('admin only sees department reports, doctor only sees own reports, super sees all', async ({ page }) => {
|
||||
await seedLocalStorage(page, {
|
||||
users: baseUsers,
|
||||
templates: baseTemplates,
|
||||
reports,
|
||||
currentUser: baseUsers[1],
|
||||
const suffix = uniqueId('perm');
|
||||
const ownTitle = `外科本人报告 ${suffix}`;
|
||||
const otherSurgeryTitle = `外科他人报告 ${suffix}`;
|
||||
const internalTitle = `内科报告 ${suffix}`;
|
||||
|
||||
await loginByApi(page, 'admin');
|
||||
const internalDepartment = await createDepartmentByApi(page.request, `内科E2E ${suffix}`, `internal_${suffix}`);
|
||||
const internalAdmin = await createUserByApi(page.request, {
|
||||
username: `internal_admin_${suffix}`,
|
||||
name: '内科E2E管理员',
|
||||
role: 'admin',
|
||||
departmentId: internalDepartment.id,
|
||||
});
|
||||
const otherSurgeryDoctor = await createUserByApi(page.request, {
|
||||
username: `surgery_doctor_${suffix}`,
|
||||
name: '外科E2E医生',
|
||||
role: 'user',
|
||||
department: '外科',
|
||||
});
|
||||
|
||||
await loginByApi(page, '0001');
|
||||
await createReportByApi(page.request, { title: ownTitle, content: `<p>${ownTitle}</p>` });
|
||||
|
||||
await loginByApi(page, otherSurgeryDoctor.username);
|
||||
await createReportByApi(page.request, { title: otherSurgeryTitle, content: `<p>${otherSurgeryTitle}</p>` });
|
||||
|
||||
await loginByApi(page, internalAdmin.username);
|
||||
await createReportByApi(page.request, { title: internalTitle, content: `<p>${internalTitle}</p>` });
|
||||
|
||||
await loginByApi(page, 'manager');
|
||||
await page.goto('/report-manage');
|
||||
|
||||
await expect(page.getByText('外科本人报告')).toBeVisible();
|
||||
await expect(page.getByText('外科他人报告')).toBeVisible();
|
||||
await expect(page.getByText('内科报告')).not.toBeVisible();
|
||||
await expect(page.getByText(ownTitle)).toBeVisible();
|
||||
await expect(page.getByText(otherSurgeryTitle)).toBeVisible();
|
||||
await expect(page.getByText(internalTitle)).not.toBeVisible();
|
||||
|
||||
await seedLocalStorage(page, {
|
||||
users: baseUsers,
|
||||
templates: baseTemplates,
|
||||
reports,
|
||||
currentUser: baseUsers[2],
|
||||
});
|
||||
await loginByApi(page, '0001');
|
||||
await page.goto('/report-manage');
|
||||
|
||||
await expect(page.getByText('外科本人报告')).toBeVisible();
|
||||
await expect(page.getByText('外科他人报告')).not.toBeVisible();
|
||||
await expect(page.getByText('内科报告')).not.toBeVisible();
|
||||
await expect(page.getByText(ownTitle)).toBeVisible();
|
||||
await expect(page.getByText(otherSurgeryTitle)).not.toBeVisible();
|
||||
await expect(page.getByText(internalTitle)).not.toBeVisible();
|
||||
|
||||
await seedLocalStorage(page, {
|
||||
users: baseUsers,
|
||||
templates: baseTemplates,
|
||||
reports,
|
||||
currentUser: baseUsers[0],
|
||||
});
|
||||
await loginByApi(page, 'admin');
|
||||
await page.goto('/report-manage');
|
||||
|
||||
await expect(page.getByText('外科本人报告')).toBeVisible();
|
||||
await expect(page.getByText('外科他人报告')).toBeVisible();
|
||||
await expect(page.getByText('内科报告')).toBeVisible();
|
||||
await expect(page.getByText(ownTitle)).toBeVisible();
|
||||
await expect(page.getByText(otherSurgeryTitle)).toBeVisible();
|
||||
await expect(page.getByText(internalTitle)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { baseTemplates, baseUsers, seedLocalStorage } from './helpers';
|
||||
import { apiRequest, createReportByApi, loginByApi, uniqueId } from './helpers';
|
||||
|
||||
test('editing a completed report increments revision and preserves history', async ({ page }) => {
|
||||
await seedLocalStorage(page, {
|
||||
users: baseUsers,
|
||||
templates: baseTemplates,
|
||||
currentUser: baseUsers[2],
|
||||
reports: [
|
||||
{
|
||||
id: 'RPT_DONE',
|
||||
title: '已完成报告',
|
||||
patientName: '患者甲',
|
||||
hospitalId: 'H001',
|
||||
department: '外科',
|
||||
content: '<p>旧报告内容</p>',
|
||||
author: '0001',
|
||||
authorName: '张医生',
|
||||
createdAt: '2026-05-01',
|
||||
updatedAt: '2026-05-01T08:00:00.000Z',
|
||||
status: 'completed',
|
||||
revision: 1,
|
||||
history: [],
|
||||
},
|
||||
],
|
||||
await loginByApi(page, '0001');
|
||||
const title = `已完成报告 ${uniqueId('revision')}`;
|
||||
const created = await createReportByApi(page.request, {
|
||||
title,
|
||||
patientName: '患者甲',
|
||||
hospitalId: uniqueId('H'),
|
||||
content: '<p>旧报告内容</p>',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await page.goto('/report-editor?id=RPT_DONE');
|
||||
await expect(page.getByText('编辑报告: RPT_DONE')).toBeVisible();
|
||||
await page.goto(`/report-editor?id=${created.id}`);
|
||||
await expect(page.getByText(`编辑报告: ${created.id}`)).toBeVisible();
|
||||
|
||||
await page.getByRole('textbox', { name: '患者姓名' }).fill('患者甲');
|
||||
await page.getByRole('textbox', { name: '住院号' }).fill(created.hospitalId);
|
||||
await page.getByRole('button', { name: '完成报告' }).click();
|
||||
await page.waitForURL('**/report-manage');
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
return JSON.parse(window.localStorage.getItem('reports') || '[]')[0];
|
||||
});
|
||||
const data = await apiRequest<{ report: any }>(page.request, 'get', `/api/reports/${created.id}`);
|
||||
const report = data.report;
|
||||
|
||||
expect(report.revision).toBe(2);
|
||||
expect(report.history).toHaveLength(1);
|
||||
|
||||
Reference in New Issue
Block a user