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; async function jsonOf(response: Response): Promise { 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 { 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 { 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'); }); });