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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user