fix(qa): 第五轮系统性质量保障缺陷修复 + 单元/集成测试

安全/鉴权:登出清服务端 httpOnly 令牌、登录 IP 限流前置 bcrypt、停用账号
令牌在 6 个内联 role handler 失效、admin/items 存在性 oracle 认证前置、
同意偏好 sanitize fail-closed、api-crypto 按 HTTP 方法判体。

CMS:workflow 状态更新+审计收进交互式 $transaction(失败即回滚)、编辑器
richtext 走 textarea 防换行损毁、array/object 子字段按作用域读写防污染、
about/contact/erp-upgrade 补 ISR revalidate 与路由映射。

UI/a11y:AnimatedCounter 入视口前显起点、CLS 累计上报、MetricCard 动画入
effect 并卸载取消 rAF、后台状态四态映射/搜索防抖+序号守卫/媒体单 input/
吞错改横幅/表单 label-aria 关联。

新增对应判别性单测与 pagination/rate-limit/client-ip/sanitize/validate-content-data
等模块及 tests-integration 真库集成层。
This commit is contained in:
2026-09-28 10:48:07 +08:00
parent 040951c0a3
commit a366bd1400
224 changed files with 8938 additions and 5579 deletions
+320
View File
@@ -0,0 +1,320 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
const mockUserRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockUserRoleCreate = jest.fn<(args?: unknown) => Promise<unknown>>();
const mockUserRoleDeleteMany = jest.fn<(args?: unknown) => Promise<unknown>>();
const mockUserRoleCount = jest.fn<(args?: unknown) => Promise<number>>();
const mockRoleFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
const mockUserFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
const mockUserCount = jest.fn<(args?: unknown) => Promise<number>>();
const mockUserCreate = jest.fn<(args?: unknown) => Promise<unknown>>();
const mockUserUpdate = jest.fn<(args?: unknown) => Promise<unknown>>();
jest.mock('@/lib/db', () => ({
prisma: {
userRole: {
findMany: mockUserRoleFindMany,
create: mockUserRoleCreate,
deleteMany: mockUserRoleDeleteMany,
count: mockUserRoleCount,
},
role: { findUnique: mockRoleFindUnique },
user: {
findUnique: mockUserFindUnique,
create: mockUserCreate,
update: mockUserUpdate,
count: mockUserCount,
},
},
}));
// ─── Mock @/lib/auth ──────────────────────────────────────────────────────
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
const mockHashPassword = jest.fn<(pw: string) => Promise<string>>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
hashPassword: mockHashPassword,
}));
import { POST, PUT } from './route';
/**
* 调用者身份 + 目标用户 URL。PUT 通过 query `?id=` 指定被改用户,
* 该参数完全由攻击者控制,是 A-2 的攻击面入口。
*/
function req(opts: {
callerId?: string;
targetId?: string;
body?: Record<string, unknown>;
}): NextRequest {
const qs = opts.targetId ? `?id=${opts.targetId}` : '';
return {
url: `http://localhost/api/admin/users${qs}`,
headers: new Headers(),
json: async () => opts.body ?? {},
} as unknown as NextRequest;
}
/** 调用者角色 → userRole.findMany 按其 userId 分支返回。 */
function callers(map: Record<string, string[]>) {
mockUserRoleFindMany.mockImplementation(async (args: unknown) => {
const userId = (args as { where: { userId: string } }).where.userId;
return (map[userId] ?? []).map((roleCode) => ({ roleCode }));
});
}
const ALL_ROLES_EXIST = async () => ({ code: 'anything', name: '角色' });
beforeEach(() => {
jest.clearAllMocks();
mockHashPassword.mockResolvedValue('hashed');
mockRoleFindUnique.mockImplementation(ALL_ROLES_EXIST);
mockUserFindUnique.mockResolvedValue(null);
// 默认调用者为「启用」账号(authenticateActiveRequest 以 user.count(id,status:1) 判活)。
mockUserCount.mockResolvedValue(1);
mockUserCreate.mockResolvedValue({
id: 'u-new',
username: 'victim',
nickname: '',
email: '',
phone: '',
});
mockUserUpdate.mockResolvedValue({ id: 'u-target' });
});
describe('POST /api/admin/users — A-1 角色授予越权', () => {
it('content_admin 不得创建 super_admin 账号', async () => {
callers({ attacker: ['content_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
const res = await POST(
req({ body: { username: 'evil', password: 'Passw0rd!', roleCodes: ['super_admin'] } })
);
expect(res.status).toBe(403);
expect(mockUserRoleCreate).not.toHaveBeenCalled();
});
it('content_admin 不得借「先建号再自我提权」绕过:只允许授予非特权角色', async () => {
callers({ attacker: ['content_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
const res = await POST(
req({ body: { username: 'evil', password: 'Passw0rd!', roleCodes: ['content_editor'] } })
);
expect(res.status).toBe(201);
expect(mockUserRoleCreate).toHaveBeenCalledWith({
data: { userId: 'u-new', roleCode: 'content_editor' },
});
});
it('super_admin 可以创建 super_admin', async () => {
callers({ boss: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'boss', username: 'boss' });
const res = await POST(
req({ body: { username: 'peer', password: 'Passw0rd!', roleCodes: ['super_admin'] } })
);
expect(res.status).toBe(201);
expect(mockUserRoleCreate).toHaveBeenCalledWith({
data: { userId: 'u-new', roleCode: 'super_admin' },
});
});
it('未声明角色时仍回落 readonly(原行为不变)', async () => {
callers({ boss: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'boss', username: 'boss' });
const res = await POST(req({ body: { username: 'plain', password: 'Passw0rd!' } }));
expect(res.status).toBe(201);
expect(mockUserRoleCreate).toHaveBeenCalledWith({
data: { userId: 'u-new', roleCode: 'readonly' },
});
});
});
describe('PUT /api/admin/users — A-2 凭据/状态接管', () => {
beforeEach(() => {
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
});
it('content_admin 不得重置他人密码', async () => {
callers({ attacker: ['content_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { password: 'Pwned123!' } }));
expect(res.status).toBe(403);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('content_admin 不得停用超管账号(DoS 接管面)', async () => {
callers({ attacker: ['content_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { status: 0 } }));
expect(res.status).toBe(403);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('content_admin 不得把超管降级为 readonly(撤销方向同样受限)', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { roleCodes: ['readonly'] } }));
expect(res.status).toBe(403);
expect(mockUserRoleDeleteMany).not.toHaveBeenCalled();
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('任何写操作前必须完成鉴权:资料字段不得先落库再被角色校验拒绝', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
const res = await PUT(
req({ targetId: 'u-target', body: { nickname: 'pwned', roleCodes: ['readonly'] } })
);
expect(res.status).toBe(403);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('super_admin 可以重置他人密码', async () => {
callers({ attacker: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
const res = await PUT(req({ targetId: 'u-target', body: { password: 'Rotated123!' } }));
expect(res.status).toBe(200);
expect(mockUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'u-target' } })
);
});
it('本人仍可修改自己的密码(自助改密不被误伤)', async () => {
callers({ me: ['content_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'me', username: 'me' });
const res = await PUT(req({ targetId: 'me', body: { password: 'Mine12345!' } }));
expect(res.status).toBe(200);
});
it('content_admin 仍可编辑普通用户的资料(未被过度收紧)', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['content_editor'] });
const res = await PUT(req({ targetId: 'u-target', body: { nickname: '新昵称' } }));
expect(res.status).toBe(200);
expect(mockUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ nickname: '新昵称' }) })
);
});
});
/**
* 账号级特权护栏的「只改资料」形态。裁定依据 ACCEPTANCE_REVIEW_2026-09-21.md §A-2 修复口径:
* 「目标持有 super_admin 时同样要求调用者为 super_admin」—— 条件挂在目标角色上,不挂在
* 「本次是否提交 roleCodes」上。故省略 roleCodes 不是放行理由,本组用例把它钉成回归契约。
*/
describe('PUT /api/admin/users — 超管账号的资料编辑(A-2 口径:整次 PUT 拒绝,非仅角色变更)', () => {
beforeEach(() => {
mockAuthenticateRequest.mockReturnValue({ userId: 'attacker', username: 'attacker' });
});
it('profile-only PUT(省略 roleCodes)改超管昵称 ⇒ 403 且零写入', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { nickname: 'pwned' } }));
expect(res.status).toBe(403);
// 零写入 = user.update / userRole.deleteMany / userRole.create 全未触达
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockUserRoleDeleteMany).not.toHaveBeenCalled();
expect(mockUserRoleCreate).not.toHaveBeenCalled();
});
it('被拒的 profile-only 请求给出「编辑超管账号」文案,而非「变动角色」文案(避免误读为实现疏漏)', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { phone: '13800000000' } }));
const payload = (await res.json()) as { error?: string };
expect(payload.error).toContain('无权编辑持有特权角色的账号');
expect(payload.error).toContain('super_admin');
expect(payload.error).not.toContain('无权变动特权角色');
});
it('真实 UI 形态(roleCodes 原样回传 + 只改昵称)同样 403 —— 与省略形态结果一致,无旁路', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['super_admin'] });
// src/app/admin/users/page.tsx 的编辑表单总是回传 roleCodes,并用目标现有角色预填
const res = await PUT(
req({ targetId: 'u-target', body: { nickname: 'pwned', roleCodes: ['super_admin'] } })
);
const payload = (await res.json()) as { error?: string };
expect(res.status).toBe(403);
expect(payload.error).toContain('无权变动特权角色');
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockUserRoleDeleteMany).not.toHaveBeenCalled();
expect(mockUserRoleCreate).not.toHaveBeenCalled();
});
it('super_admin 改超管账号资料仍放行(护栏按调用者分级,不是把该账号封死)', async () => {
callers({ attacker: ['super_admin'], 'u-target': ['super_admin'] });
const res = await PUT(req({ targetId: 'u-target', body: { nickname: '正确昵称' } }));
expect(res.status).toBe(200);
expect(mockUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ nickname: '正确昵称' }) })
);
});
it('省略 roleCodes 不是绕开凭据校验的后门:改普通用户状态依旧 403', async () => {
callers({ attacker: ['content_admin'], 'u-target': ['content_editor'] });
const res = await PUT(req({ targetId: 'u-target', body: { status: 0 } }));
expect(res.status).toBe(403);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
});
/**
* Q-4(N-20 同类):这些 handler 走 authenticateRequest + 内联 role 检查,曾完全不看 User.status,
* 于是「被停用但仍持 ≤24h 有效令牌」的超管仍能列/建/改/删用户。修复后须经
* authenticateActiveRequest 的存活判定(user.count(id,status:1))在角色检查与任何写入之前拒绝。
*/
describe('Q-4 停用账号令牌:admin/users 一律 401 且零写入', () => {
it('停用的 super_admin 不能创建用户', async () => {
callers({ ghost: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'ghost', username: 'ghost' });
mockUserCount.mockResolvedValue(0); // status !== 1 ⇒ 判为不存活
const res = await POST(
req({ body: { username: 'evil', password: 'Passw0rd!', roleCodes: ['content_editor'] } })
);
expect(res.status).toBe(401);
expect(mockUserCreate).not.toHaveBeenCalled();
expect(mockUserRoleCreate).not.toHaveBeenCalled();
});
it('停用的 super_admin 不能编辑他人(含重置密码)', async () => {
callers({ ghost: ['super_admin'] });
mockAuthenticateRequest.mockReturnValue({ userId: 'ghost', username: 'ghost' });
mockUserCount.mockResolvedValue(0);
const res = await PUT(req({ targetId: 'u-target', body: { password: 'Pwned123!' } }));
expect(res.status).toBe(401);
expect(mockUserUpdate).not.toHaveBeenCalled();
});
});
+72 -22
View File
@@ -1,12 +1,14 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest, hashPassword } from '@/lib/auth';
import { hashPassword } from '@/lib/auth';
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
import { authenticateActiveRequest, mayChangeCredentials, privilegedRolesInPlay } from '@/lib/permissions';
import { withCrypto } from '@/lib/api-crypto';
import { parsePagination } from '@/lib/pagination';
// GET /api/admin/users - 获取用户列表
export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
const user = await authenticateActiveRequest(request);
if (!user) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
@@ -17,8 +19,7 @@ export const GET = withCrypto(async (request: NextRequest) => {
try {
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10)));
const { page, pageSize } = parsePagination(searchParams, { defaultPageSize: 20, maxPageSize: 100 });
const search = searchParams.get('search') || '';
const where = search
@@ -82,7 +83,7 @@ export const GET = withCrypto(async (request: NextRequest) => {
// POST /api/admin/users - 创建用户
export const POST = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request);
const user = await authenticateActiveRequest(request);
if (!user) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
@@ -113,6 +114,19 @@ export const POST = withCrypto(async (request: NextRequest) => {
return validationError('密码长度不能少于 6 位');
}
// 分配角色:先定角色再建号,避免越权请求留下无角色的垃圾账号
const rolesToAssign = body.roleCodes?.length ? body.roleCodes : ['readonly'];
const blocked = privilegedRolesInPlay(roleCodes, rolesToAssign, []);
if (blocked.length > 0) {
return forbidden(`无权授予特权角色:${blocked.join(', ')}`);
}
// 残留风险(**按设计保留**,非疏漏):护栏只拦 PRIVILEGED_ROLE_CODES,故 content_admin 仍可
// 建出同类(授予 content_admin)乃至 content_editor / reviewer 等自己不持有的角色。
// 与 roles/route.ts 对比:该路由的 GET/PUT 一律限 super_admin,且显式禁改 super_admin 自身权限,
// 即「角色能做什么」由超管独占,而「谁能被分配一个非特权角色」下放给内容管理员 ——
// 这是 A-1 修复口径(ACCEPTANCE_REVIEW §A-1:仅要求「非 super_admin 不得授予 super_admin」)
// 刻意留下的授权面;收窄它需另行裁定,不在本次变更范围内。
// 检查用户名是否已存在
const existing = await prisma.user.findUnique({ where: { username: body.username } });
if (existing) {
@@ -131,8 +145,6 @@ export const POST = withCrypto(async (request: NextRequest) => {
},
});
// 分配角色
const rolesToAssign = body.roleCodes?.length ? body.roleCodes : ['readonly'];
for (const roleCode of rolesToAssign) {
const role = await prisma.role.findUnique({ where: { code: roleCode } });
if (role) {
@@ -158,7 +170,7 @@ export const POST = withCrypto(async (request: NextRequest) => {
// PUT /api/admin/users - 更新用户
export const PUT = withCrypto(async (request: NextRequest) => {
const currentUser = authenticateRequest(request);
const currentUser = await authenticateActiveRequest(request);
if (!currentUser) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } });
@@ -181,13 +193,61 @@ export const PUT = withCrypto(async (request: NextRequest) => {
roleCodes?: string[];
};
const isTargetSelf = userId === currentUser.userId;
const targetRoleCodes = (await prisma.userRole.findMany({ where: { userId } })).map(
(ur) => ur.roleCode
);
// ── 全部授权判定必须先于任何写入 ──
// 原实现在 user.update 之后才做角色校验,被拒时资料字段已经落库。
//
// 账号级特权护栏(验收 A-1 / A-2):调用者非 super_admin 时,只要该账号「变更前或变更后」
// 处于 super_admin,就整次 PUT 拒绝 —— 包括 body.roleCodes 缺省的「只改资料」形态。
// 省略 roleCodes 时把目标现有角色并入判定是**裁定过的策略**,不是回落到默认值的疏漏:
// · 依据 ACCEPTANCE_REVIEW_2026-09-21.md §A-2 修复口径 ——「目标持有 super_admin 时同样要求
// 调用者为 super_admin」,条件挂在目标的角色上,而非「本次是否改角色」上。非超管调用者
// 改超管资料,本质仍是「在不居于其上的账号上写入」:昵称/邮箱会展示在用户列表
// (users/page.tsx 表格)与超管自己的后台侧栏、仪表盘上,改之即可冒充该身份、
// 在管理面内制造误导。这条边界不因请求少了个键而消失。
// · 仓库内唯一的真实调用方 src/app/admin/users/page.tsx 的编辑表单**总是**回传 roleCodes
// (handleSave 组包处),且 openEditDialog 用目标现有角色预填,所以「省略 roleCodes」
// 只可能来自手写/被篡改的请求,不存在被误伤的正常用户流程。
// · 若只在 body.roleCodes 存在时才判定,同一意图(改超管昵称)会因为「有没有带那个键」
// 而一个 403 一个 200,UI 那条仍然 403 —— 策略不可解释,且给直接调 API 的客户端留了旁路。
const resultingRoleCodes = body.roleCodes ?? targetRoleCodes;
const blockedRoles = privilegedRolesInPlay(roleCodes, resultingRoleCodes, targetRoleCodes);
if (blockedRoles.length > 0) {
// 文案按「是否提交角色」分开,避免把资料编辑误读成角色校验写错了对象
return forbidden(
body.roleCodes
? `无权变动特权角色:${blockedRoles.join(', ')}`
: `无权编辑持有特权角色的账号:${blockedRoles.join(', ')}(仅超级管理员可修改其资料)`
);
}
// 不能移除自己的超级管理员角色(否则会自锁在管理面之外)
if (
isTargetSelf &&
body.roleCodes &&
targetRoleCodes.includes('super_admin') &&
!body.roleCodes.includes('super_admin')
) {
return forbidden('不能移除自己的超级管理员角色');
}
const wantsPassword = Boolean(body.password);
const wantsStatus = body.status !== undefined;
if ((wantsPassword || wantsStatus) && !mayChangeCredentials(roleCodes, isTargetSelf)) {
return forbidden('仅超级管理员可重置他人密码或变更他人状态');
}
const updateData: Record<string, unknown> = {};
if (body.nickname !== undefined) updateData.nickname = body.nickname;
if (body.email !== undefined) updateData.email = body.email;
if (body.phone !== undefined) updateData.phone = body.phone;
if (body.status !== undefined) updateData.status = body.status;
if (body.password) {
updateData.password = await hashPassword(body.password);
if (wantsStatus) updateData.status = body.status;
if (wantsPassword) {
updateData.password = await hashPassword(body.password as string);
}
await prisma.user.update({
@@ -197,16 +257,6 @@ export const PUT = withCrypto(async (request: NextRequest) => {
// 更新角色分配
if (body.roleCodes) {
// 防止将自己从 super_admin 移除
if (userId === currentUser.userId) {
const currentRoles = await prisma.userRole.findMany({ where: { userId } });
const currentIsSuperAdmin = currentRoles.some((r) => r.roleCode === 'super_admin');
const stillHasSuperAdmin = body.roleCodes.includes('super_admin');
if (currentIsSuperAdmin && !stillHasSuperAdmin) {
return forbidden('不能移除自己的超级管理员角色');
}
}
await prisma.userRole.deleteMany({ where: { userId } });
for (const roleCode of body.roleCodes) {
const role = await prisma.role.findUnique({ where: { code: roleCode } });
@@ -225,7 +275,7 @@ export const PUT = withCrypto(async (request: NextRequest) => {
// DELETE /api/admin/users - 删除用户
export const DELETE = withCrypto(async (request: NextRequest) => {
const currentUser = authenticateRequest(request);
const currentUser = await authenticateActiveRequest(request);
if (!currentUser) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } });