Files
novalon-website/tests-integration/03-authorization.itest.ts
T
zhangxiang 6bb7c557ee 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
  配置收敛。
2026-09-28 10:48:08 +08:00

297 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});