- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注 skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets 等复用桩。 - 集成层:config/test/jest.integration.config.js + tests-integration/ 真库 一次性 SQLite 用例,teardown 守卫开发库指纹。 - 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、 check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse 配置收敛。
216 lines
8.3 KiB
TypeScript
216 lines
8.3 KiB
TypeScript
import { afterAll, beforeEach, describe, expect, it } from '@jest/globals';
|
||
import {
|
||
API,
|
||
accessTokenFor,
|
||
disconnect,
|
||
makeRequest,
|
||
prisma,
|
||
resetDb,
|
||
seedModel,
|
||
seedRole,
|
||
seedUser,
|
||
unique,
|
||
type SeededUser,
|
||
} from './helpers/harness';
|
||
import { POST as createItem } from '@/app/api/admin/items/route';
|
||
import { POST as workflowPost } from '@/app/api/admin/items/[id]/workflow/route';
|
||
import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types';
|
||
|
||
type Json = Record<string, unknown>;
|
||
|
||
async function jsonOf(response: Response): Promise<Json> {
|
||
return (await response.json()) as Json;
|
||
}
|
||
|
||
async function workflow(
|
||
itemId: string,
|
||
token: string,
|
||
body: Json,
|
||
): Promise<{ status: number; body: Json }> {
|
||
const response = await workflowPost(
|
||
makeRequest({ url: API.workflow(itemId), method: 'POST', token, body }),
|
||
{ params: Promise.resolve({ id: itemId }) },
|
||
);
|
||
return { status: response.status, body: await jsonOf(response) };
|
||
}
|
||
|
||
interface Fixture {
|
||
code: string;
|
||
modelId: string;
|
||
editor: SeededUser;
|
||
editorToken: string;
|
||
reviewer: SeededUser;
|
||
reviewerToken: string;
|
||
}
|
||
|
||
/**
|
||
* 两个真实账号 + 两套真实权限行:
|
||
* editor 只有 create/read/update(无 publish),reviewer 额外有 publish。
|
||
* 这样「谁能推进到哪一步」由数据库里的 Permission 表决定,而不是由 mock 决定。
|
||
*/
|
||
async function seedWorkflowFixture(): Promise<Fixture> {
|
||
const code = unique('case-study');
|
||
const { id } = await seedModel(code, CONTENT_TYPE_CONFIGS['case-study'].model.fields);
|
||
|
||
await seedRole(`editor-${code}`, [
|
||
{ modelCode: code, action: 'create' },
|
||
{ modelCode: code, action: 'read' },
|
||
{ modelCode: code, action: 'update' },
|
||
]);
|
||
await seedRole(`reviewer-${code}`, [
|
||
{ modelCode: code, action: 'read' },
|
||
{ modelCode: code, action: 'publish' },
|
||
]);
|
||
|
||
const editor = await seedUser(`editor-${code}`, [`editor-${code}`]);
|
||
const reviewer = await seedUser(`reviewer-${code}`, [`reviewer-${code}`]);
|
||
return {
|
||
code,
|
||
modelId: id,
|
||
editor,
|
||
editorToken: accessTokenFor(editor),
|
||
reviewer,
|
||
reviewerToken: accessTokenFor(reviewer),
|
||
};
|
||
}
|
||
|
||
async function createDraft(fx: Fixture, slug: string, token?: string): Promise<string> {
|
||
const response = await createItem(
|
||
makeRequest({
|
||
url: API.items,
|
||
method: 'POST',
|
||
token: token ?? fx.editorToken,
|
||
body: {
|
||
modelId: fx.modelId,
|
||
modelCode: fx.code,
|
||
title: `工作流用例 ${slug}`,
|
||
slug,
|
||
data: { client: '某制造集团', color: 'blue' },
|
||
},
|
||
}),
|
||
);
|
||
return String((await jsonOf(response)).id ?? '');
|
||
}
|
||
|
||
/**
|
||
* A-8 (d):发布工作流在真实库上的完整链路。
|
||
* 单测里 workflow.ts 的 prisma 是 jest.setup.js 的假对象,
|
||
* 所以「状态机是否真的落库、审计与通知是否真的写入」从未被验证过。
|
||
*/
|
||
describe('发布工作流(真实 SQLite)', () => {
|
||
beforeEach(async () => {
|
||
await resetDb();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await disconnect();
|
||
});
|
||
|
||
it('draft → review → publish 全程落库:状态、版本、publishedAt、审计、通知', async () => {
|
||
const fx = await seedWorkflowFixture();
|
||
const itemId = await createDraft(fx, 'itest-happy');
|
||
expect(itemId).not.toBe('');
|
||
|
||
const initial = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
|
||
expect(initial.status).toBe('draft');
|
||
expect(initial.publishedAt).toBeNull();
|
||
|
||
// 1) 提交审核(editor 有 update 权限)
|
||
const submitted = await workflow(itemId, fx.editorToken, { action: 'submit' });
|
||
expect(submitted.status).toBe(200);
|
||
expect(submitted.body.status).toBe('review');
|
||
let row = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
|
||
expect(row.status).toBe('review');
|
||
// 审核人收到一条真实的通知行
|
||
expect(await prisma.notification.count({ where: { userId: fx.reviewer.id, type: 'review_pending' } })).toBe(1);
|
||
|
||
// 2) editor 无 publish 权限 → 403,且状态必须停在 review
|
||
const forbiddenAttempt = await workflow(itemId, fx.editorToken, { action: 'approve' });
|
||
expect(forbiddenAttempt.status).toBe(403);
|
||
row = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
|
||
expect(row.status).toBe('review');
|
||
|
||
// 3) reviewer 审核通过 → published,publishedAt 落库
|
||
const approved = await workflow(itemId, fx.reviewerToken, { action: 'approve' });
|
||
expect(approved.status).toBe(200);
|
||
expect(approved.body.status).toBe('published');
|
||
row = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
|
||
expect(row.status).toBe('published');
|
||
expect(row.publishedAt).toBeInstanceOf(Date);
|
||
expect(row.version).toBe(initial.version + 2);
|
||
|
||
// 4) 审计链:submit / approve 两步各写一行 AuditLog
|
||
const logs = await prisma.auditLog.findMany({ where: { targetId: itemId }, orderBy: { createdAt: 'asc' } });
|
||
expect(logs.length).toBeGreaterThanOrEqual(2);
|
||
expect(logs.map((l) => l.operator)).toContain(fx.reviewer.username);
|
||
|
||
// 5) 公开读取路径此刻才看得见这条内容
|
||
const published = await prisma.contentItem.findMany({ where: { modelCode: fx.code, status: 'published' } });
|
||
expect(published.map((i) => i.id)).toContain(itemId);
|
||
});
|
||
|
||
it('非法流转被拒绝且库内状态不变;驳回回到 draft 并通知创建人', async () => {
|
||
const fx = await seedWorkflowFixture();
|
||
const itemId = await createDraft(fx, 'itest-reject');
|
||
|
||
// draft 不能直接 approve
|
||
const bad = await workflow(itemId, fx.reviewerToken, { action: 'approve' });
|
||
expect(bad.status).toBe(400);
|
||
expect((await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } })).status).toBe('draft');
|
||
|
||
// draft 不能直接 archive
|
||
expect((await workflow(itemId, fx.reviewerToken, { action: 'archive' })).status).toBe(400);
|
||
|
||
await workflow(itemId, fx.editorToken, { action: 'submit' });
|
||
const rejected = await workflow(itemId, fx.reviewerToken, { action: 'reject', reason: '数据口径缺佐证' });
|
||
expect(rejected.status).toBe(200);
|
||
const row = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
|
||
expect(row.status).toBe('draft');
|
||
expect(
|
||
await prisma.notification.count({ where: { userId: fx.editor.id, type: 'review_rejected' } }),
|
||
).toBe(1);
|
||
});
|
||
|
||
it('B-1:POST /api/admin/items 携带 status:"published" 不得绕过工作流与 publish 权限', async () => {
|
||
const fx = await seedWorkflowFixture();
|
||
const before = await prisma.contentItem.count({ where: { modelCode: fx.code, status: 'published' } });
|
||
|
||
const bypass = await createItem(
|
||
makeRequest({
|
||
url: `${API.items}?status=published`,
|
||
method: 'POST',
|
||
token: fx.editorToken, // 该账号只有 create/read/update,没有 publish
|
||
body: {
|
||
modelId: fx.modelId,
|
||
modelCode: fx.code,
|
||
title: '越权直发',
|
||
slug: 'itest-bypass',
|
||
status: 'published',
|
||
data: { client: '某集团', color: 'blue' },
|
||
},
|
||
}),
|
||
);
|
||
|
||
// 契约(两选一):要么被拒(403/400),要么走工作流(落库状态为 review/draft)。
|
||
// 当前实现既不拒绝也不流转:POST 只要求 'create' 权限(route.ts:132),
|
||
// 却把 body.status 原样写库(route.ts:160)并顺手补上 publishedAt(route.ts:165)。
|
||
// ⇒ 只有 create、没有 publish 的账号可越过 draft→review→publish 直接发布。本用例保持为红。
|
||
const persisted = await prisma.contentItem.count({ where: { modelCode: fx.code, status: 'published' } });
|
||
expect(persisted).toBe(before);
|
||
expect([400, 403]).toContain(bypass.status);
|
||
});
|
||
|
||
it('未知 action 与不存在的条目分别返回 400 / 404,且不产生任何审计', async () => {
|
||
const fx = await seedWorkflowFixture();
|
||
const itemId = await createDraft(fx, 'itest-badargs');
|
||
|
||
expect((await workflow(itemId, fx.editorToken, { action: 'publish' })).status).toBe(400);
|
||
expect((await workflow(itemId, fx.editorToken, {})).status).toBe(400);
|
||
expect((await workflow('no-such-id', fx.editorToken, { action: 'submit' })).status).toBe(404);
|
||
|
||
const logs = await prisma.auditLog.findMany({ where: { targetId: 'no-such-id' } });
|
||
expect(logs).toHaveLength(0);
|
||
expect((await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } })).status).toBe('draft');
|
||
});
|
||
});
|