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