test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构

- 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
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
@@ -0,0 +1,92 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import fs from 'node:fs';
import path from 'node:path';
import { prisma, resetDb, disconnect, unique, seedModel } from './helpers/harness';
const DEV_DB = path.resolve(__dirname, '..', 'prisma', 'dev.db');
interface DbFingerprint {
exists: boolean;
ino: number | null;
size: number | null;
mtimeMs: number | null;
}
interface Manifest {
datasourceUrl: string;
baseline: Record<string, DbFingerprint>;
}
/** globalSetup 落盘的运行期清单(与临时库同目录,见 config/test/itest/env.js) */
function readManifest(): Manifest {
const dbFile = process.env.NOVALON_ITEST_DB;
if (!dbFile) throw new Error('NOVALON_ITEST_DB 未设置:worker 未经过 config/test/itest/setup-files.js');
const file = path.join(path.dirname(dbFile), 'novalon-itest-manifest.json');
return JSON.parse(fs.readFileSync(file, 'utf8')) as Manifest;
}
/**
* 安全边界规格:这个文件不测业务,它测的是「其余所有 itest 到底连在哪个库上」。
* 它是 A-8 修复的前置条件 —— 集成层一旦误连 prisma/dev.db,这里必须变红。
*/
describe('集成层数据源边界', () => {
beforeAll(async () => {
await resetDb();
});
afterAll(async () => {
await disconnect();
});
it('打开的是临时目录里的一次性 SQLite 文件,项目数据源指纹未变', async () => {
// 1) 引擎自报打开的文件 —— 不是「我们以为的路径」,而是 SQLite 实际 attach 的路径
const attached = await prisma.$queryRawUnsafe<Array<{ file: string }>>('PRAGMA database_list');
const file = attached[0]?.file ?? '';
expect(file).toMatch(/novalon-itest\.db$/);
expect(file).not.toMatch(/(^|[/\\])(dev|data|test|prod)\.db$/);
// 2) 真的在跟 SQLite 说话:引擎版本号只有真实引擎给得出,任何 jest mock 都造不出来
const version = await prisma.$queryRawUnsafe<Array<{ v: string }>>('SELECT sqlite_version() AS v');
expect(version[0]?.v).toMatch(/^\d+\.\d+\.\d+$/);
// 3) worker 的 DATABASE_URL 与 globalSetup 落盘的 manifest 一致,且是绝对 file: 路径
const envUrl = process.env.DATABASE_URL ?? '';
const manifest = readManifest();
expect(envUrl.startsWith('file:/')).toBe(true);
expect(manifest.datasourceUrl).toBe(envUrl);
expect(envUrl).toBe(`file:${process.env.NOVALON_ITEST_DB}`);
// 4) 项目数据源在集成测试运行前后是同一个文件且完全未写(inode + size + mtime 三重比对)
const now = fs.statSync(DEV_DB);
const before = manifest.baseline.prismaDevDb;
if (!before) throw new Error('manifest 缺少 prismaDevDb 基线,守卫无法比对');
expect([now.ino, now.size, Math.round(now.mtimeMs)]).toEqual([
before.ino,
before.size,
before.mtimeMs === null ? null : Math.round(before.mtimeMs),
]);
});
it('应用的 schema 带真实约束(唯一索引与外键都生效,不是空表壳)', async () => {
const code = unique('it-model');
const { id } = await seedModel(code, []);
await prisma.contentItem.create({
data: { modelId: id, modelCode: code, title: '第一版', slug: 'dup', data: '{}' },
});
// 同一 (modelCode, slug, locale) 的第二次写入必须被真实唯一索引拒绝
await expect(
prisma.contentItem.create({
data: { modelId: id, modelCode: code, title: '第二版', slug: 'dup', data: '{}' },
}),
).rejects.toMatchObject({ code: 'P2002' });
// modelId 指向不存在的模型必须被真实外键(ContentItem_modelId_fkey)拒绝
await expect(
prisma.contentItem.create({
data: { modelId: 'no-such-model', modelCode: code, title: '孤儿', slug: unique('orphan'), data: '{}' },
}),
).rejects.toThrow();
});
});
+274
View File
@@ -0,0 +1,274 @@
import { afterAll, beforeEach, describe, expect, it } from '@jest/globals';
import {
API,
accessTokenFor,
disconnect,
makeRequest,
prisma,
rawDataColumn,
resetDb,
seedModel,
seedRole,
seedUser,
unique,
type SeededUser,
} from './helpers/harness';
import { POST as createItem, GET as listItems, PUT as updateItem } from '@/app/api/admin/items/route';
import { POST as workflowPost } from '@/app/api/admin/items/[id]/workflow/route';
import { getPublishedItems } from '@/lib/cms/data-server';
import { parseCmsData } from '@/lib/cms/validate-content-data';
import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types';
import type { FieldDefinition } from '@/lib/cms/types';
type Json = Record<string, unknown>;
async function jsonOf(response: Response): Promise<Json> {
return (await response.json()) as Json;
}
// 字段声明直接取自生产注册表(零编造):service 的 metrics[] 带 select 型 basis 字段,
// 见 src/lib/cms/content-types.ts:8-18 与 :176-186。
const SERVICE_FIELDS: FieldDefinition[] = CONTENT_TYPE_CONFIGS.service.model.fields;
function declaredOptions(arrayField: string, fieldName: string): string[] {
const parent = SERVICE_FIELDS.find((f) => f.name === arrayField);
const target = parent?.fields?.find((f) => f.name === fieldName);
const options = (target?.options ?? []).map((o) => String(o.value));
if (options.length === 0) {
throw new Error(`生产字段声明里找不到 ${arrayField}.${fieldName} 的 options,测试前提已失效`);
}
return options;
}
interface EditableModel {
code: string;
id: string;
editor: SeededUser;
token: string;
}
/** 每个用例一套独立的 modelCode:既隔离权限,也绕开 data-server 里 React cache() 的按参数记忆 */
async function seedEditableModel(withPublish = true): Promise<EditableModel> {
const code = unique('service');
const { id } = await seedModel(code, SERVICE_FIELDS);
const roleCode = `role-${code}`;
const actions = ['create', 'read', 'update', 'delete', ...(withPublish ? ['publish'] : [])];
await seedRole(roleCode, actions.map((action) => ({ modelCode: code, action })));
const editor = await seedUser(`editor-${code}`, [roleCode]);
return { code, id, editor, token: accessTokenFor(editor) };
}
async function createDraft(
model: EditableModel,
slug: string,
data: Json,
): Promise<string> {
const response = await createItem(
makeRequest({
url: API.items,
method: 'POST',
token: model.token,
body: { modelId: model.id, modelCode: model.code, title: `集成用例 ${slug}`, slug, data },
}),
);
const body = await jsonOf(response);
return String(body.id ?? '');
}
/** 发布态只能来自 workflow 流转(B-1:POST /items 拒绝调用方自带 status) */
async function publishThroughWorkflow(model: EditableModel, itemId: string): Promise<void> {
for (const action of ['submit', 'approve'] as const) {
const response = await workflowPost(
makeRequest({
url: API.workflow(itemId),
method: 'POST',
token: model.token,
body: { action },
}),
{ params: Promise.resolve({ id: itemId }) },
);
expect(response.status).toBe(200);
}
}
/**
* A-8 (a)/(b):真实读写路径上的 CMS JSON 列。
* 单测里 `@/lib/cms/data-server` 被整体 mock(jest.setup.js:29-50)、`next/server` 被替换成
* 手写假对象(jest.setup.js:90-109),所以「脏列让公开读取崩掉」「写入侧不校验声明」
* 这两类缺陷在单测层永远不可见。
*/
describe('CMS 内容读写(真实 SQLite)', () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await disconnect();
});
it('POST 落库的是一行真实记录,GET 读回来的是解析后的对象', async () => {
const model = await seedEditableModel();
const basis = declaredOptions('metrics', 'basis');
const itemId = await createDraft(model, 'itest-service', {
metrics: [{ value: '12', label: '交付项目', basis: basis[0] ?? 'target' }],
});
expect(itemId).not.toBe('');
// 真库断言:行确实存在,data 列是字符串
expect(await prisma.contentItem.count({ where: { id: itemId } })).toBe(1);
expect(typeof (await rawDataColumn(itemId))).toBe('string');
const list = await listItems(makeRequest({ url: `${API.items}?modelCode=${model.code}`, token: model.token }));
expect(list.status).toBe(200);
const body = await jsonOf(list);
const items = body.items as Json[];
expect(items).toHaveLength(1);
expect(items[0]?.data).toMatchObject({ metrics: [{ value: '12' }] });
expect(body.total).toBe(1);
});
it('(a) PUT data:null 落库为 "{}",读取路径拿到空对象而不是崩溃', async () => {
const model = await seedEditableModel();
const itemId = await createDraft(model, 'itest-null-data', { a: 1 });
const updated = await updateItem(
makeRequest({ url: `${API.items}?id=${itemId}`, method: 'PUT', token: model.token, body: { data: null } }),
);
expect(updated.status).toBe(200);
// 库里落的是 '{}',而不是 'null' —— 后者会让读取侧 JSON.parse 产出 null 并在解引用时炸掉
expect(await rawDataColumn(itemId)).toBe('{}');
const list = await listItems(makeRequest({ url: `${API.items}?modelCode=${model.code}`, token: model.token }));
const items = (await jsonOf(list)).items as Json[];
expect(items.find((i) => i.id === itemId)?.data).toEqual({});
});
it('(a) 直接写进库里的脏 data 列不会让公开读取路径崩溃(parseCmsData 守卫)', async () => {
const dirtyValues = ['not-json{', '', 'null', '42', '"just a string"'];
for (const dirty of dirtyValues) {
const model = await seedEditableModel();
const itemId = await createDraft(model, 'itest-dirty', { a: 1 });
expect(itemId).not.toBe('');
await publishThroughWorkflow(model, itemId);
const written = await prisma.$executeRawUnsafe(
'UPDATE "ContentItem" SET data = ? WHERE id = ?',
dirty,
itemId,
);
expect(written).toBe(1);
expect(await rawDataColumn(itemId)).toBe(dirty);
// 公开页面读取路径:data-server → 真实 prisma → toContentItem → parseCmsData
const published = await getPublishedItems(model.code);
expect(published).toHaveLength(1);
expect(published[0]?.data).toEqual({});
// 管理端读取路径:route.ts 的 parseItem
const list = await listItems(
makeRequest({ url: `${API.items}?modelCode=${model.code}`, token: model.token }),
);
expect(list.status).toBe(200);
const items = (await jsonOf(list)).items as Json[];
expect(items.find((i) => i.id === itemId)?.data).toEqual({});
}
// 守卫对非字符串入参的分支(真库 data 列是 NOT NULL,SQL NULL 只能来自历史脏库/手工修数据)
expect(parseCmsData(null)).toEqual({});
expect(parseCmsData(undefined)).toEqual({});
});
it('(b) 声明了 options 的字段:越界 basis 值被 400 拒绝,且一行都不落库', async () => {
const model = await seedEditableModel();
const basis = declaredOptions('metrics', 'basis');
const forged = 'customer-verified-2026';
expect(basis).not.toContain(forged);
expect(await prisma.contentItem.count({ where: { modelCode: model.code } })).toBe(0);
const rejected = await createItem(
makeRequest({
url: API.items,
method: 'POST',
token: model.token,
body: {
modelId: model.id,
modelCode: model.code,
title: '越界口径',
slug: 'itest-forged',
data: { metrics: [{ value: '99%', label: '客户满意度', basis: forged }] },
},
}),
);
expect(rejected.status).toBe(400);
const body = await jsonOf(rejected);
expect(body.code).toBe('VALIDATION_ERROR');
expect(JSON.stringify(body)).toContain('metrics[0].basis');
expect(await prisma.contentItem.count({ where: { modelCode: model.code } })).toBe(0);
});
it('(b) PUT 越界 basis 值被 400 拒绝,已落库的 data 列与 version 保持原值', async () => {
const model = await seedEditableModel();
const basis = declaredOptions('metrics', 'basis');
const itemId = await createDraft(model, 'itest-put-basis', {
metrics: [{ value: '1', label: 'x', basis: basis[0] ?? 'target' }],
});
const original = await rawDataColumn(itemId);
const rejected = await updateItem(
makeRequest({
url: `${API.items}?id=${itemId}`,
method: 'PUT',
token: model.token,
body: { data: { metrics: [{ value: '1', label: 'x', basis: 'VERIFIED' }] } },
}),
);
expect(rejected.status).toBe(400);
expect(await rawDataColumn(itemId)).toBe(original);
// 拒绝必须完全无副作用:版本号不能被推进
const stored = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
expect(stored.version).toBe(1);
expect(stored.updatedBy).toBe(model.editor.username);
});
it('(B-3 能力边界,非豁免声明)basis:"verified" 本身是合法 options,枚举校验拦不住伪造口径', async () => {
// 这条用例钉住的是「写入侧校验做不到什么」:content-types.ts:16 把 'verified' 列为合法选项,
// 所以 validateContentData 的 options 检查对「无实测出处却声称 verified」完全无感。
// 需要的是出处校验(B-3 的另一半),不是枚举校验。断言当前真实行为,行为变化即变红。
const model = await seedEditableModel();
const basis = declaredOptions('metrics', 'basis');
expect(basis).toContain('verified');
const itemId = await createDraft(model, 'itest-verified-claim', {
metrics: [{ value: '100%', label: '客户续约率', basis: 'verified' }],
});
expect(itemId).not.toBe('');
expect(await rawDataColumn(itemId)).toContain('verified');
});
it('(a) 状态流转成功后,脏 data 列不得让 workflow 路由 500(route.ts:21 用的是裸 JSON.parse)', async () => {
const model = await seedEditableModel();
const itemId = await createDraft(model, 'itest-wf-dirty', { a: 1 });
await prisma.$executeRawUnsafe('UPDATE "ContentItem" SET data = ? WHERE id = ?', 'broken json {', itemId);
const response = await workflowPost(
makeRequest({
url: API.workflow(itemId),
method: 'POST',
token: model.token,
body: { action: 'submit' },
}),
{ params: Promise.resolve({ id: itemId }) },
);
// 先证明写侧已经提交:状态确实流转成了 review。
// 于是不通过的下一条断言只可能指向一件事 —— 读回阶段把已提交的写变成了 500。
const stored = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
expect(stored.status).toBe('review');
// 契约:脏列不得让响应崩溃(items/route.ts 同一位置已改用 parseCmsData,这里仍是裸 JSON.parse)。
// 当前为红:workflow/route.ts:21 抛 SyntaxError → internalError() 500,而写已落库(B-4 未收口)。
expect(response.status).toBe(200);
});
});
@@ -0,0 +1,215 @@
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');
});
});
+296
View File
@@ -0,0 +1,296 @@
import { afterAll, beforeEach, describe, expect, it } from '@jest/globals';
import {
API,
accessTokenFor,
disconnect,
makeRequest,
prisma,
refreshTokenFor,
resetDb,
seedRole,
seedUser,
unique,
type SeededUser,
} from './helpers/harness';
import { GET as readRoles, PUT as updateRolePermissions } from '@/app/api/admin/roles/route';
import { POST as createUser, PUT as updateUser } from '@/app/api/admin/users/route';
import { POST as refresh } from '@/app/api/auth/refresh/route';
import { verifyAccessToken, verifyPassword, verifyRefreshToken } from '@/lib/auth';
import { checkUserPermission } from '@/lib/permissions';
type Json = Record<string, unknown>;
async function jsonOf(response: Response): Promise<Json> {
return (await response.json()) as Json;
}
/** 真实 super_admin / content_admin 各一个,权限行来自库里而不是 mock */
async function seedAdmins(): Promise<{ super: SeededUser; content: SeededUser; victim: SeededUser }> {
// super_admin 带一条真实 Permission 行,这样「拒绝对它的改写」是可观测的(行数必须不变)
await seedRole('super_admin', [{ modelCode: 'service', action: 'read' }]);
await seedRole('content_admin', [{ modelCode: 'service', action: 'create' }]);
await seedRole('readonly');
const superUser = await seedUser(`root-${unique('u')}`, ['super_admin']);
const contentAdmin = await seedUser(`ca-${unique('u')}`, ['content_admin']);
const victim = await seedUser(`victim-${unique('u')}`, ['readonly']);
return { super: superUser, content: contentAdmin, victim };
}
/**
* A-8 (c)(e)(f):账号/角色/令牌三条写路径在真实库上的行为。
* 对应验收报告 A-1(自助提权)、A-2(改密接管)、B-5(无事务的角色权限重写)、B-7(禁用账号刷新)。
*/
describe('授权与账号写路径(真实 SQLite)', () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await disconnect();
});
it('(e) content_admin 给自己/他人授予 super_admin ⇒ 403,且真实库里没有新用户、没有新 UserRole', async () => {
const { content } = await seedAdmins();
const username = `escalated-${unique('u')}`;
const usersBefore = await prisma.user.count();
const rolesBefore = await prisma.role.count();
const userRolesBefore = await prisma.userRole.count();
const response = await createUser(
makeRequest({
url: API.users,
method: 'POST',
token: accessTokenFor(content),
body: { username, password: 'Passw0rd-2026', roleCodes: ['super_admin'] },
}),
);
expect(response.status).toBe(403);
// A-1 的修复要求「先判角色再建号」:所以库里必须一个字节都没多
expect(await prisma.user.count()).toBe(usersBefore);
expect(await prisma.user.findUnique({ where: { username } })).toBeNull();
expect(await prisma.userRole.count()).toBe(userRolesBefore);
expect(await prisma.role.count()).toBe(rolesBefore);
// 顺带确认 super_admin 角色本身没被凭空创建出来
expect(await prisma.userRole.count({ where: { roleCode: 'super_admin' } })).toBe(1);
});
it('(e) content_admin 重置 super_admin 口令 / 停用超管 ⇒ 403,真实哈希与 status 未变', async () => {
const { content, super: root } = await seedAdmins();
const before = await prisma.user.findUniqueOrThrow({ where: { id: root.id } });
const pw = await updateUser(
makeRequest({
url: `${API.users}?id=${root.id}`,
method: 'PUT',
token: accessTokenFor(content),
body: { password: 'taken-over-2026' },
}),
);
expect(pw.status).toBe(403);
expect((await prisma.user.findUniqueOrThrow({ where: { id: root.id } })).password).toBe(before.password);
const st = await updateUser(
makeRequest({
url: `${API.users}?id=${root.id}`,
method: 'PUT',
token: accessTokenFor(content),
body: { status: 0 },
}),
);
expect(st.status).toBe(403);
expect((await prisma.user.findUniqueOrThrow({ where: { id: root.id } })).status).toBe(1);
// 超管仍能用原口令登录(哈希确实没被替换)
const stored = await prisma.user.findUniqueOrThrow({ where: { id: root.id } });
expect(stored.status).toBe(1);
expect(await verifyPassword(root.password, stored.password)).toBe(true);
});
it('(c 机制对照) 同一个 Prisma 客户端里,$transaction 之外的写会立刻落盘 —— 证明上面那条回滚断言不是空断言', async () => {
// 这条用例不碰任何业务 route,只测 Prisma 在真实 SQLite 上的提交语义:
// 逐条 await 的写法(验收报告 B-5 的原始实现)在第一句 deleteMany 之后就已经把
// 「权限被清空」写进了磁盘,后续 create 抛错并不会撤回它。
// 若本对照用例变红,说明环境的提交语义变了,那么下一条「回滚」用例无论route怎么写都会通过 ——
// 也就是那条用例只有在下面这个前提成立时才有意义。
const code = `ctl-${unique('r')}`;
await seedRole(code, [
{ modelCode: 'service', action: 'read' },
{ modelCode: 'service', action: 'update' },
{ modelCode: 'case-study', action: 'read' },
]);
expect(await prisma.permission.count({ where: { roleCode: code } })).toBe(3);
await prisma.permission.deleteMany({ where: { roleCode: code } });
await prisma.permission.create({ data: { roleCode: code, modelCode: 'news', action: 'read' } });
await expect(
prisma.permission.create({
data: { roleCode: code, modelCode: 42 as unknown as string, action: 'read' },
}),
).rejects.toThrow();
// 孤立写入的结果:旧权限已被清空,半途的新权限留了下来 ⇒ 角色被踢出管理面
const stranded = await prisma.permission.findMany({ where: { roleCode: code } });
expect(stranded.map((p) => `${p.modelCode}:${p.action}`)).toEqual(['news:read']);
});
it('(c) 角色权限重写必须原子:第二条 insert 失败后,原有权限集不得已经被清空', async () => {
const { super: root } = await seedAdmins();
const code = `perm-${unique('r')}`;
await seedRole(code, [
{ modelCode: 'service', action: 'read' },
{ modelCode: 'service', action: 'update' },
{ modelCode: 'case-study', action: 'read' },
]);
const before = await prisma.permission.findMany({ where: { roleCode: code }, orderBy: { modelCode: 'asc' } });
expect(before).toHaveLength(3);
// 第二条权限的 modelCode 类型非法 ⇒ 真实 Prisma 校验异常(不是 mock 抛的假错)
const response = await updateRolePermissions(
makeRequest({
url: API.roles,
method: 'PUT',
token: accessTokenFor(root),
body: {
roleCode: code,
permissions: [
{ modelCode: 'news', action: 'read' },
{ modelCode: 42 as unknown as string, action: 'read' },
{ modelCode: 'product', action: 'read' },
],
},
}),
);
const after = await prisma.permission.findMany({ where: { roleCode: code } });
expect(response.status).toBe(500);
// 契约:写失败 ⇒ 权限集必须回到调用前。status 500 已经证明「第二条 create 真的炸了」,
// 因此下面这条等式只有在**整体回滚**时才可能成立。
// 背景(验收 B-5):原实现先 deleteMany 再逐条 await create,第二条失败时角色权限已被清空,
// 该角色的全部使用者会被一次性踢出管理面。route.ts 现已改为单次 $transaction 提交,
// 本用例即该修复的回归守卫 —— 谁把事务拆回去,这里就变红。
expect(after.map((p) => `${p.modelCode}:${p.action}`)).toEqual(
before.map((p) => `${p.modelCode}:${p.action}`),
);
// 第一条在孤立写入下本会留下(news:read),这里必须查不到
expect(after.some((p) => p.modelCode === 'news')).toBe(false);
});
it('(c 正向对照) 合法的角色权限重写在真实库上完整生效,且 GET 需要 super_admin', async () => {
const { super: root, content } = await seedAdmins();
const code = `ok-${unique('r')}`;
await seedRole(code, [{ modelCode: 'service', action: 'read' }]);
const ok = await updateRolePermissions(
makeRequest({
url: API.roles,
method: 'PUT',
token: accessTokenFor(root),
body: { roleCode: code, permissions: [{ modelCode: 'service', action: 'read' }, { modelCode: 'service', action: 'publish' }] },
}),
);
expect(ok.status).toBe(200);
expect(await prisma.permission.count({ where: { roleCode: code } })).toBe(2);
// 非超管不能读角色权限矩阵
expect((await readRoles(makeRequest({ url: API.roles, token: accessTokenFor(content) }))).status).toBe(403);
// super_admin 自身的权限不可改
const locked = await updateRolePermissions(
makeRequest({
url: API.roles,
method: 'PUT',
token: accessTokenFor(root),
body: { roleCode: 'super_admin', permissions: [] },
}),
);
expect(locked.status).toBe(403);
expect(await prisma.permission.count({ where: { roleCode: 'super_admin' } })).toBe(1);
});
it('(f) 被真实禁用的账号拿 refresh token 换不到新 access token;启用账号则能换到', async () => {
const { super: root } = await seedAdmins();
const disabled = await seedUser(`disabled-${unique('u')}`, ['readonly'], { status: 0 });
// 旧 refresh token 的签名依旧有效(7 天窗口内)⇒ 下面那次 401 只能来自「查库后拒绝」,
// 这正是 B-7 的修复点:原实现只验签名即用旧 payload 重签。
const staleRefresh = refreshTokenFor(disabled);
expect(verifyRefreshToken(staleRefresh).userId).toBe(disabled.id);
const response = await refresh(
makeRequest({ url: API.refresh, method: 'POST', body: { refreshToken: staleRefresh } }),
);
expect(response.status).toBe(401);
const body = await jsonOf(response);
// 必须是「账号状态」这一分支拒绝,而不是「令牌无效」—— 两者都是 401,但含义完全不同
expect(body.error).toContain('登录状态已失效');
expect(body.accessToken).toBeUndefined();
expect(body.refreshToken).toBeUndefined();
expect(response.headers.getSetCookie()).toEqual([]);
// 正向对照:启用账号确实能换到可用的新令牌并真的下发 cookie
// (证明上面那个 401 来自账号状态检查,而不是环境或签名坏了)
const enabled = await refresh(
makeRequest({ url: API.refresh, method: 'POST', body: { refreshToken: refreshTokenFor(root) } }),
);
expect(enabled.status).toBe(200);
const newToken = String((await jsonOf(enabled)).accessToken);
expect(verifyAccessToken(newToken).userId).toBe(root.id);
const cookies = enabled.headers.getSetCookie().join('\n');
expect(cookies).toContain('novalon_token=');
expect(cookies).toContain('novalon_refresh=');
expect(cookies).toContain('HttpOnly');
});
});
/**
* N-20(真实 SQLite):停用/删除账号后,已签发的访问令牌不应再换取到任何权限。
* 单测里 checkUserPermission 的 prisma 是 mock,证明不了「查询形状真的命中库」;
* 这里用真实 Permission / UserRole 行跑同一条判定。
*/
describe('账号状态对权限判定的真实作用(N-20,真实 SQLite)', () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await disconnect();
});
it('活跃账号凭真实 Permission 行放行,停用后同一角色同一权限立刻拒绝', async () => {
await seedRole('content_editor', [{ modelCode: 'news', action: 'create' }]);
const editor = await seedUser('ed-' + unique('u'), ['content_editor']);
await expect(checkUserPermission(editor.id, 'news', 'create')).resolves.toBe(true);
await prisma.user.update({ where: { id: editor.id }, data: { status: 0 } });
await expect(checkUserPermission(editor.id, 'news', 'create')).resolves.toBe(false);
});
it('被停用的 super_admin 不能靠角色短路放行(状态校验必须先于 super_admin 分支)', async () => {
await seedRole('super_admin', [{ modelCode: 'service', action: 'read' }]);
const root = await seedUser('root-' + unique('u'), ['super_admin']);
await expect(checkUserPermission(root.id, 'service', 'read')).resolves.toBe(true);
await prisma.user.update({ where: { id: root.id }, data: { status: 0 } });
await expect(checkUserPermission(root.id, 'service', 'read')).resolves.toBe(false);
});
it('账号行不存在 ⇒ 拒绝', async () => {
await expect(
checkUserPermission('no-such-user-' + unique('u'), 'news', 'read')
).resolves.toBe(false);
});
it('机制对照:删号会级联清掉 UserRole ⇒ N-20 的真实暴露面是 status=0 而非删号', async () => {
await seedRole('content_editor', [{ modelCode: 'news', action: 'create' }]);
const editor = await seedUser('ed-' + unique('u'), ['content_editor']);
await prisma.user.delete({ where: { id: editor.id } });
// `UserRole.user` 声明了 onDelete: Cascade(prisma/schema.prisma),所以删号后角色关联一并消失。
// ⇒ 只看角色表的旧实现在「删号」这一支会因为 roleCodes.length === 0 而**恰好**拒绝;
// 真正的漏洞只在 status = 0(账号行还在、关联还在),即上面第一条用例。
await expect(prisma.userRole.count({ where: { userId: editor.id } })).resolves.toBe(0);
await expect(checkUserPermission(editor.id, 'news', 'create')).resolves.toBe(false);
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* 真库集成测试的公共夹具。
*
* 这里没有任何 jest.mock —— 被测代码与 SQLite 之间不存在替身:
* `@/lib/db` 走的是 src/generated/prisma 的真实 PrismaClient,
* `@/lib/auth` 走的是真实 JWT 签名/校验(密钥由 config/test/itest/setup-files.js 注入,
* 刻意不读 .env*)。
*/
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { generateTokens, hashPassword } from '@/lib/auth';
import type { FieldDefinition } from '@/lib/cms/types';
export { prisma };
let counter = 0;
/** 同一 worker 内共享一个库文件,唯一名避免用例间互相污染 */
export function unique(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now().toString(36)}-${counter}`;
}
/** 按 FK 依赖顺序清空全部业务表(保留表结构) */
export async function resetDb(): Promise<void> {
await prisma.auditLog.deleteMany();
await prisma.notification.deleteMany();
await prisma.contentItem.deleteMany();
await prisma.contentZone.deleteMany();
await prisma.mediaAsset.deleteMany();
await prisma.permission.deleteMany();
await prisma.userRole.deleteMany();
await prisma.role.deleteMany();
await prisma.contentModel.deleteMany();
await prisma.user.deleteMany();
}
export async function seedRole(code: string, permissions: Array<{ modelCode: string; action: string }> = []): Promise<void> {
await prisma.role.create({ data: { code, name: code } });
for (const p of permissions) {
await prisma.permission.create({ data: { roleCode: code, modelCode: p.modelCode, action: p.action } });
}
}
export interface SeededUser {
id: string;
username: string;
password: string;
}
export async function seedUser(
username: string,
roleCodes: string[],
options: { password?: string; status?: number } = {},
): Promise<SeededUser> {
const password = options.password ?? 'Integration-test-2026';
const user = await prisma.user.create({
data: {
username,
// 真实 bcrypt 哈希:集成层不 mock @/lib/auth,登录/刷新走的是真校验
password: await hashPassword(password),
status: options.status ?? 1,
role: roleCodes[0] ?? 'readonly',
},
});
for (const roleCode of roleCodes) {
await prisma.userRole.create({ data: { userId: user.id, roleCode } });
}
return { id: user.id, username: user.username, password };
}
/** 真实签名的 access token(与 src/lib/auth 的 verifyAccessToken 对称) */
export function accessTokenFor(user: SeededUser, role = 'admin'): string {
return generateTokens({ userId: user.id, username: user.username, role }).accessToken;
}
export function refreshTokenFor(user: SeededUser, role = 'admin'): string {
return generateTokens({ userId: user.id, username: user.username, role }).refreshToken;
}
export interface RequestOptions {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: unknown;
token?: string;
headers?: Record<string, string>;
}
/**
* 构造真实 NextRequest —— 与仓库里既有的 route.test.ts 同一入口形态,
* 但这里不替换 headers/url/json,NextRequest 自己解析 cookie 与 searchParams。
*/
export function makeRequest(options: RequestOptions): NextRequest {
const method = options.method ?? 'GET';
const withBody = options.body !== undefined && method !== 'GET';
const headers: Record<string, string> = { ...options.headers };
if (options.token) headers.authorization = `Bearer ${options.token}`;
if (withBody) headers['content-type'] = 'application/json';
// 字面量直接内联,让 init 按 next/server 自己的 RequestInit 形状做上下文推断
return new NextRequest(options.url, {
method,
headers,
...(withBody ? { body: JSON.stringify(options.body) } : {}),
});
}
export const API = {
items: 'http://localhost:3000/api/admin/items',
users: 'http://localhost:3000/api/admin/users',
roles: 'http://localhost:3000/api/admin/roles',
refresh: 'http://localhost:3000/api/auth/refresh',
workflow: (id: string): string =>
`http://localhost:3000/api/admin/items/${id}/workflow`,
};
/** 读取 ContentItem.data 的**原始列值**(绕过任何解析,证明落库内容) */
export async function rawDataColumn(id: string): Promise<string> {
const rows = await prisma.$queryRawUnsafe<Array<{ data: string }>>(
`SELECT data FROM "ContentItem" WHERE id = ?`,
id,
);
return rows[0]?.data ?? '';
}
/** 以给定字段声明建一个内容模型,返回 { id, code } */
export async function seedModel(code: string, fields: FieldDefinition[]): Promise<{ id: string; code: string }> {
const model = await prisma.contentModel.create({
data: { code, name: code, fields: JSON.stringify(fields) },
});
return { id: model.id, code: model.code };
}
export async function disconnect(): Promise<void> {
await prisma.$disconnect();
}