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