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:
@@ -36,6 +36,12 @@ jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
// Q-8:workflow POST 现会先 authenticateRequest 再探存在性;默认给有效会话。
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { POST } from './route';
|
||||
@@ -77,6 +83,7 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContentItemFindUnique.mockResolvedValue(mockItem);
|
||||
mockContentItemFindUniqueOrThrow.mockResolvedValue(mockUpdatedItem);
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
});
|
||||
|
||||
describe('POST /api/admin/items/[id]/workflow', () => {
|
||||
@@ -111,6 +118,16 @@ describe('POST /api/admin/items/[id]/workflow', () => {
|
||||
expect(body.error).toBe('内容不存在');
|
||||
});
|
||||
|
||||
// Q-8:匿名调用者不得用「404 vs 401」枚举 ID —— 认证须先于存在性探针。
|
||||
it('rejects an unauthenticated workflow with 401 WITHOUT probing item existence', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const request = createMockRequest({ action: 'submit' });
|
||||
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockContentItemFindUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submit requires update permission', async () => {
|
||||
mockAuthorized('update');
|
||||
const request = createMockRequest({ action: 'submit' });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import {
|
||||
submitForReview,
|
||||
approve,
|
||||
@@ -11,14 +12,17 @@ import {
|
||||
import {
|
||||
success,
|
||||
notFound,
|
||||
unauthorized,
|
||||
validationError,
|
||||
internalError,
|
||||
forbidden,
|
||||
} from '@/lib/api-response';
|
||||
import { withCrypto } from '@/lib/api-crypto';
|
||||
import { parseCmsData } from '@/lib/cms/validate-content-data';
|
||||
|
||||
// 与 items/route.ts 同源:脏 data 列不得让这个已成功的审批动作在返回阶段变成 500(B-4)。
|
||||
function parseItem(item: { data: string; [key: string]: unknown }) {
|
||||
return { ...item, data: JSON.parse(item.data as string) };
|
||||
return { ...item, data: parseCmsData(item.data) };
|
||||
}
|
||||
|
||||
interface WorkflowBody {
|
||||
@@ -51,6 +55,9 @@ export const POST = withCrypto(async (
|
||||
return validationError(`非法的 action: ${action}`);
|
||||
}
|
||||
|
||||
// Q-8:认证先于存在性探针,避免匿名枚举内容 ID。
|
||||
if (!authenticateRequest(request)) return unauthorized();
|
||||
|
||||
const existing = await prisma.contentItem.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('内容不存在');
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const mockContentItemCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentItemUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentItemDelete = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockContentModelFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
@@ -22,6 +23,9 @@ jest.mock('@/lib/db', () => ({
|
||||
update: mockContentItemUpdate,
|
||||
delete: mockContentItemDelete,
|
||||
},
|
||||
contentModel: {
|
||||
findUnique: mockContentModelFindUnique,
|
||||
},
|
||||
auditLog: {
|
||||
create: mockAuditLogCreate,
|
||||
},
|
||||
@@ -37,9 +41,27 @@ jest.mock('@/lib/permissions', () => ({
|
||||
requirePermission: mockRequirePermission,
|
||||
}));
|
||||
|
||||
// Q-8:PUT/DELETE 现会先 authenticateRequest 再探存在性;默认给出有效会话,
|
||||
// 让既有的「已授权」用例继续走通,枚举用例单独置空。
|
||||
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
jest.unmock('./route');
|
||||
|
||||
import { GET, POST, PUT, DELETE } from './route';
|
||||
import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types';
|
||||
|
||||
const productFields = CONTENT_TYPE_CONFIGS['product'].model.fields;
|
||||
|
||||
function mockProductModel() {
|
||||
mockContentModelFindUnique.mockResolvedValue({
|
||||
id: 'model-1',
|
||||
code: 'product',
|
||||
fields: JSON.stringify(productFields),
|
||||
});
|
||||
}
|
||||
|
||||
function createMockRequest(options: {
|
||||
url?: string;
|
||||
@@ -98,6 +120,9 @@ beforeEach(() => {
|
||||
mockContentItemUpdate.mockResolvedValue(mockItem);
|
||||
mockContentItemDelete.mockResolvedValue(undefined);
|
||||
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
|
||||
mockContentModelFindUnique.mockResolvedValue(null);
|
||||
// Q-8:默认视为已登录(合法会话),使「已授权」路径不受存在性前置门影响。
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'content_editor' });
|
||||
});
|
||||
|
||||
describe('/api/admin/items', () => {
|
||||
@@ -131,6 +156,42 @@ describe('/api/admin/items', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Q-7:`page`/`pageSize` 未经校验会把 NaN 传进 Prisma skip/take(真库抛错→500),
|
||||
// 且 pageSize 无界可被用来整表拉取。修复后经 parsePagination 收敛为有界默认值。
|
||||
it('sanitizes non-numeric page and clamps oversized pageSize (no NaN/ unbounded take)', async () => {
|
||||
mockAuthorized();
|
||||
const response = await GET(
|
||||
createMockRequest({ url: 'http://localhost/api/admin/items?page=abc&pageSize=99999' })
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.page).toBe(1);
|
||||
expect(body.pageSize).toBe(100);
|
||||
expect(mockContentItemFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skip: 0, take: 100 })
|
||||
);
|
||||
});
|
||||
|
||||
// Q-8:匿名调用者不得通过「404(存在) vs 401(不存在)」枚举内容 ID —— 认证必须先于存在性探针。
|
||||
it('rejects an unauthenticated PUT with 401 WITHOUT probing item existence (no ID oracle)', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const response = await PUT(
|
||||
createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' })
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockContentItemFindUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated DELETE with 401 WITHOUT probing item existence (no ID oracle)', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
const response = await DELETE(
|
||||
createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' })
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockContentItemFindUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by modelCode and status', async () => {
|
||||
mockAuthorized();
|
||||
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&status=published' }));
|
||||
@@ -162,6 +223,19 @@ describe('/api/admin/items', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('applies id filter so the editor can locate one item without paging', async () => {
|
||||
mockAuthorized();
|
||||
await GET(
|
||||
createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&id=item-101&page=1&pageSize=1' })
|
||||
);
|
||||
|
||||
expect(mockContentItemFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { modelCode: 'news', id: 'item-101' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
@@ -200,7 +274,7 @@ describe('/api/admin/items', () => {
|
||||
expect(body.error).toContain('已存在');
|
||||
});
|
||||
|
||||
it('creates item and audit log', async () => {
|
||||
it('creates item as draft and writes audit log', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({
|
||||
@@ -209,7 +283,6 @@ describe('/api/admin/items', () => {
|
||||
title: '新新闻',
|
||||
slug: 'new-news',
|
||||
data: { body: 'content' },
|
||||
status: 'published',
|
||||
sortOrder: 1,
|
||||
}),
|
||||
}));
|
||||
@@ -224,9 +297,9 @@ describe('/api/admin/items', () => {
|
||||
modelCode: 'news',
|
||||
title: '新新闻',
|
||||
slug: 'new-news',
|
||||
status: 'published',
|
||||
status: 'draft',
|
||||
sortOrder: 1,
|
||||
publishedAt: expect.any(Date),
|
||||
publishedAt: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -240,6 +313,49 @@ describe('/api/admin/items', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// B-1:create 权限不得成为发布通道,且未知 status 不能入库(否则 workflow 表外状态会永久卡死)。
|
||||
it.each(['published', 'review', 'archived', '任意字符串'])(
|
||||
'refuses status "%s" on create and writes nothing',
|
||||
async (status) => {
|
||||
mockAuthorized();
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({
|
||||
modelId: 'model-1',
|
||||
modelCode: 'news',
|
||||
title: '新新闻',
|
||||
data: { body: 'content' },
|
||||
status,
|
||||
}),
|
||||
}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('workflow');
|
||||
expect(mockContentItemCreate).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogCreate).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('treats an explicit draft status as the default (no bypass, no rejection)', async () => {
|
||||
mockAuthorized();
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({
|
||||
modelId: 'model-1',
|
||||
modelCode: 'news',
|
||||
title: '新新闻',
|
||||
data: { body: 'content' },
|
||||
status: 'draft',
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockContentItemCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'draft', publishedAt: null }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
@@ -275,6 +391,21 @@ describe('/api/admin/items', () => {
|
||||
expect(body.error).toContain('状态变更');
|
||||
});
|
||||
|
||||
it('accepts a PUT that echoes the unchanged status (admin autosave sends it every time)', async () => {
|
||||
mockAuthorized();
|
||||
const response = await PUT(createMockRequest({
|
||||
url: 'http://localhost/api/admin/items?id=item-1',
|
||||
json: async () => ({ title: '更新', status: 'draft' }),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentItemUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ title: '更新' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 400 when slug duplicated', async () => {
|
||||
mockAuthorized();
|
||||
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'duplicated' });
|
||||
@@ -359,4 +490,142 @@ describe('/api/admin/items', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('写入侧字段声明校验(B-3)', () => {
|
||||
it('rejects a metric basis outside the declared options before any write', async () => {
|
||||
mockAuthorized();
|
||||
mockProductModel();
|
||||
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({
|
||||
modelId: 'model-1',
|
||||
modelCode: 'product',
|
||||
title: '虚构指标产品',
|
||||
data: { metrics: [{ value: '99.9%', label: '系统可用率', basis: 'customer-proven' }] },
|
||||
}),
|
||||
}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.code).toBe('VALIDATION_ERROR');
|
||||
expect(body.details.fields).toEqual([
|
||||
expect.objectContaining({ path: 'metrics[0].basis', rule: 'option' }),
|
||||
]);
|
||||
expect(mockContentItemCreate).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a payload that stays inside the declared constraints', async () => {
|
||||
mockAuthorized();
|
||||
mockProductModel();
|
||||
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({
|
||||
modelId: 'model-1',
|
||||
modelCode: 'product',
|
||||
title: 'ERP 套件',
|
||||
data: {
|
||||
metrics: [{ value: '30%', label: '库存周转提升', basis: 'target' }],
|
||||
categoryId: 'enterprise',
|
||||
status: '研发中',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockContentItemCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
data: JSON.stringify({
|
||||
metrics: [{ value: '30%', label: '库存周转提升', basis: 'target' }],
|
||||
categoryId: 'enterprise',
|
||||
status: '研发中',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves keys the model never declares alone', async () => {
|
||||
mockAuthorized();
|
||||
mockProductModel();
|
||||
|
||||
const response = await POST(createMockRequest({
|
||||
json: async () => ({
|
||||
modelId: 'model-1',
|
||||
modelCode: 'product',
|
||||
title: '含未声明键',
|
||||
data: { notDeclaredAtAll: { basis: 'whatever' }, freeform: '任意结构' },
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
|
||||
it('stays permissive when the model row is missing or its fields column is dirty', async () => {
|
||||
mockAuthorized();
|
||||
mockContentModelFindUnique.mockResolvedValue({ id: 'model-1', code: 'product', fields: 'null' });
|
||||
|
||||
const missingModel = await POST(createMockRequest({
|
||||
json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '无模型', data: {} }),
|
||||
}));
|
||||
expect(missingModel.status).toBe(201);
|
||||
|
||||
mockContentItemCreate.mockClear();
|
||||
|
||||
const dirtyFields = await POST(createMockRequest({
|
||||
json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '脏声明', data: {} }),
|
||||
}));
|
||||
expect(dirtyFields.status).toBe(201);
|
||||
});
|
||||
|
||||
it('rejects an invalid PUT payload before touching the row', async () => {
|
||||
mockAuthorized();
|
||||
mockProductModel();
|
||||
mockContentItemFindUnique.mockResolvedValue({ ...mockItem, modelCode: 'product' });
|
||||
|
||||
const response = await PUT(createMockRequest({
|
||||
url: 'http://localhost/api/admin/items?id=item-1',
|
||||
json: async () => ({ data: { status: '已上线' } }),
|
||||
}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.details.fields).toEqual([
|
||||
expect.objectContaining({ path: 'status', rule: 'option' }),
|
||||
]);
|
||||
expect(mockContentItemUpdate).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('脏 data 列不再打断读写(B-4)', () => {
|
||||
it('returns {} instead of 500 when a stored data column is not valid JSON', async () => {
|
||||
mockAuthorized();
|
||||
mockContentItemFindMany.mockResolvedValue([{ ...mockItem, data: 'null' }]);
|
||||
|
||||
const response = await GET(createMockRequest({}));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.items[0].data).toEqual({});
|
||||
});
|
||||
|
||||
it('serialises a null PUT payload as {} so the column stays readable', async () => {
|
||||
mockAuthorized();
|
||||
mockContentItemFindUnique.mockResolvedValue({ ...mockItem, data: 'null' });
|
||||
|
||||
const response = await PUT(createMockRequest({
|
||||
url: 'http://localhost/api/admin/items?id=item-1',
|
||||
json: async () => ({ data: null }),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockContentItemUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ data: '{}' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import {
|
||||
success,
|
||||
notFound,
|
||||
unauthorized,
|
||||
validationError,
|
||||
internalError,
|
||||
} from '@/lib/api-response';
|
||||
import { withCrypto } from '@/lib/api-crypto';
|
||||
import { parsePagination } from '@/lib/pagination';
|
||||
import {
|
||||
parseCmsData,
|
||||
validateContentData,
|
||||
type ContentFieldError,
|
||||
} from '@/lib/cms/validate-content-data';
|
||||
import type { FieldDefinition } from '@/lib/cms/types';
|
||||
|
||||
function parseItem(item: { data: string; [key: string]: unknown }) {
|
||||
return { ...item, data: JSON.parse(item.data as string) };
|
||||
return { ...item, data: parseCmsData(item.data) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取模型的字段声明用于写入侧校验(B-3)。
|
||||
* 模型缺失或 fields 脏数据时返回空数组,即不施加任何声明约束,保持既有宽松行为。
|
||||
*/
|
||||
async function loadDeclaredFields(modelCode: string): Promise<FieldDefinition[]> {
|
||||
try {
|
||||
const model = await prisma.contentModel.findUnique({ where: { code: modelCode } });
|
||||
if (!model) return [];
|
||||
const fields = parseCmsData<FieldDefinition[]>(model.fields, []);
|
||||
return Array.isArray(fields) ? fields : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function fieldValidationError(errors: ContentFieldError[]) {
|
||||
return validationError('内容字段校验未通过', { fields: errors });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,11 +86,13 @@ export const GET = withCrypto(async (request: NextRequest) => {
|
||||
try {
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('search');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '10');
|
||||
const id = searchParams.get('id');
|
||||
const { page, pageSize } = parsePagination(searchParams, { maxPageSize: 100, defaultPageSize: 10 });
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (modelCode) where.modelCode = modelCode;
|
||||
// 编辑器按 id 定位单条内容:分页窗口(前 N 条)之外的条目否则永远取不到
|
||||
if (id) where.id = id;
|
||||
if (status) where.status = status;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
@@ -107,6 +137,20 @@ export const POST = withCrypto(async (request: NextRequest) => {
|
||||
const permission = await requirePermission(request, modelCode, 'create');
|
||||
if ('response' in permission) return permission.response;
|
||||
|
||||
// B-1:创建接口不能成为发布通道。status 只接受缺省或 'draft',其余取值一律拒绝并指向
|
||||
// workflow 接口(那里才校验 publish 权限与合法流转)。这同时堵掉了任意 status 字符串
|
||||
// 入库后「所有动作都返回 false、条目永久卡死且无反馈」的情况。
|
||||
if (status !== undefined && status !== 'draft') {
|
||||
return validationError(
|
||||
'新建内容只能保存为草稿,状态变更请使用 /api/admin/items/[id]/workflow 接口',
|
||||
);
|
||||
}
|
||||
|
||||
const fieldErrors = validateContentData(await loadDeclaredFields(modelCode), data);
|
||||
if (fieldErrors.length > 0) {
|
||||
return fieldValidationError(fieldErrors);
|
||||
}
|
||||
|
||||
// 未提供 slug 时自动生成唯一 slug;提供时检查唯一性
|
||||
const finalSlug = slug
|
||||
? slug
|
||||
@@ -127,12 +171,12 @@ export const POST = withCrypto(async (request: NextRequest) => {
|
||||
modelCode,
|
||||
title,
|
||||
slug: finalSlug,
|
||||
status: status || 'draft',
|
||||
status: 'draft',
|
||||
data: JSON.stringify(data || {}),
|
||||
sortOrder: sortOrder || 0,
|
||||
createdBy: permission.user.username,
|
||||
updatedBy: permission.user.username,
|
||||
publishedAt: status === 'published' ? new Date() : null,
|
||||
publishedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,6 +204,9 @@ export const PUT = withCrypto(async (request: NextRequest) => {
|
||||
const id = searchParams.get('id');
|
||||
if (!id) return validationError('缺少 ID');
|
||||
|
||||
// 存在性探针必须在认证之后:否则匿名调用者可用「404=存在 vs 401=不存在」枚举内容 ID(Q-8)。
|
||||
if (!authenticateRequest(request)) return unauthorized();
|
||||
|
||||
const existing = await prisma.contentItem.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('内容不存在');
|
||||
|
||||
@@ -180,11 +227,20 @@ export const PUT = withCrypto(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 状态流转必须通过 /api/admin/items/[id]/workflow 进行
|
||||
if (status !== undefined) {
|
||||
// 状态流转必须通过 /api/admin/items/[id]/workflow 进行。但「原样回传当前状态」不是流转:
|
||||
// 管理端每次保存(含自动保存)都会把现有 status 一起 PUT 上来,若一概拒绝会让所有编辑保存变 400。
|
||||
if (status !== undefined && status !== existing.status) {
|
||||
return validationError('状态变更请使用 /api/admin/items/[id]/workflow 接口');
|
||||
}
|
||||
|
||||
const fieldErrors = validateContentData(
|
||||
await loadDeclaredFields(existing.modelCode),
|
||||
data,
|
||||
);
|
||||
if (fieldErrors.length > 0) {
|
||||
return fieldValidationError(fieldErrors);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedBy: permission.user.username,
|
||||
updatedAt: new Date(),
|
||||
@@ -194,7 +250,8 @@ export const PUT = withCrypto(async (request: NextRequest) => {
|
||||
if (title !== undefined) updateData.title = title;
|
||||
// slug 为空字符串时保留原值,避免触发唯一约束冲突
|
||||
if (slug !== undefined && slug !== '') updateData.slug = slug;
|
||||
if (data !== undefined) updateData.data = JSON.stringify(data);
|
||||
// data 为 null 时与 POST 同样落库为 `{}`,避免脏列让读取侧整页崩溃(B-4)
|
||||
if (data !== undefined) updateData.data = JSON.stringify(data ?? {});
|
||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||
|
||||
const item = await prisma.contentItem.update({
|
||||
@@ -227,6 +284,9 @@ export const DELETE = withCrypto(async (request: NextRequest) => {
|
||||
const id = searchParams.get('id');
|
||||
if (!id) return validationError('缺少 ID');
|
||||
|
||||
// Q-8:认证先于存在性探针,避免匿名枚举内容 ID。
|
||||
if (!authenticateRequest(request)) return unauthorized();
|
||||
|
||||
const existing = await prisma.contentItem.findUnique({ where: { id } });
|
||||
if (!existing) return notFound('内容不存在');
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ jest.mock('@/lib/media/media-service', () => ({
|
||||
}));
|
||||
|
||||
import { GET, POST, DELETE } from './route';
|
||||
import { UploadPolicyError } from '@/lib/media/upload-policy';
|
||||
|
||||
function createMockRequest(options: {
|
||||
url?: string;
|
||||
@@ -206,6 +207,42 @@ describe('/api/admin/media', () => {
|
||||
expect(body.error).toContain('10MB');
|
||||
expect(mockUploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Q-9:多文件批次里第二个文件未过策略时,第一个已成功写入的文件必须被回滚删除,
|
||||
// 否则孤儿资产落盘 + 入库却无返回、无清理路径。旧实现无回滚 ⇒ mockDeleteMedia 不被调用 ⇒ RED。
|
||||
it('rolls back already-uploaded files when a later file fails the policy check', async () => {
|
||||
mockAuthorized();
|
||||
mockUploadMedia
|
||||
.mockResolvedValueOnce({ id: 'asset-1' })
|
||||
.mockRejectedValueOnce(new UploadPolicyError('非法文件类型'));
|
||||
mockDeleteMedia.mockResolvedValue(undefined);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('files', createTestFile('a', 'a.png', 'image/png'));
|
||||
formData.append('files', createTestFile('b', 'b.png', 'image/png'));
|
||||
|
||||
const response = await POST(createMockRequest({ formData: async () => formData }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain('非法文件类型');
|
||||
expect(mockDeleteMedia).toHaveBeenCalledWith('asset-1');
|
||||
});
|
||||
|
||||
// Q-9:预检尺寸——批次中任一文件超限,须在任何写入发生前整批拒绝。
|
||||
it('rejects the whole batch without uploading when one file is oversized', async () => {
|
||||
mockAuthorized();
|
||||
mockUploadMedia.mockResolvedValue({ id: 'asset-x' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('files', createTestFile('ok', 'ok.png', 'image/png'));
|
||||
formData.append('files', createTestFile('big', 'big.png', 'image/png', 10 * 1024 * 1024 + 1));
|
||||
|
||||
const response = await POST(createMockRequest({ formData: async () => formData }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockUploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
validationError,
|
||||
internalError,
|
||||
} from '@/lib/api-response';
|
||||
import { UploadPolicyError } from '@/lib/media/upload-policy';
|
||||
import { withCrypto } from '@/lib/api-crypto';
|
||||
|
||||
const MODEL_CODE = 'media';
|
||||
@@ -68,30 +69,49 @@ export const POST = withCrypto(async (request: NextRequest) => {
|
||||
return validationError('请选择文件');
|
||||
}
|
||||
|
||||
const results = [];
|
||||
// 预检尺寸:任一无关文件超限即整批拒绝,且此时尚未写入任何文件(不产生孤儿)。
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return validationError(`文件 "${file.name}" 大小不能超过 10MB`);
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
const results: Awaited<ReturnType<typeof uploadMedia>>[] = [];
|
||||
try {
|
||||
for (const file of files) {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
const asset = await uploadMedia(
|
||||
{
|
||||
name: file.name,
|
||||
buffer,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
},
|
||||
permission.user.username
|
||||
);
|
||||
const asset = await uploadMedia(
|
||||
{
|
||||
name: file.name,
|
||||
buffer,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
},
|
||||
permission.user.username
|
||||
);
|
||||
|
||||
results.push(asset);
|
||||
results.push(asset);
|
||||
}
|
||||
} catch (error) {
|
||||
// 多文件批次中途失败(如某个文件未过 magic-byte/类型策略):回滚本请求已成功写入的文件,
|
||||
// 否则前面的文件已落盘 + 入库,而请求返回错误 → 形成无返回、无清理路径的孤儿资产(Q-9)。
|
||||
if (results.length > 0) {
|
||||
await Promise.all(results.map((a) => deleteMedia(a.id).catch(() => {})));
|
||||
}
|
||||
if (error instanceof UploadPolicyError) {
|
||||
return validationError(error.message);
|
||||
}
|
||||
console.error('Upload media error:', error);
|
||||
return internalError();
|
||||
}
|
||||
|
||||
return success(files.length === 1 ? results[0] : results, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof UploadPolicyError) {
|
||||
return validationError(error.message);
|
||||
}
|
||||
console.error('Upload media error:', error);
|
||||
return internalError();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { NextRequest } from 'next/server';
|
||||
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
|
||||
const mockRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockPermissionFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockPermissionDeleteMany = jest.fn<(args?: unknown) => Promise<unknown>>();
|
||||
const mockPermissionCreate = jest.fn<(args?: unknown) => Promise<unknown>>();
|
||||
const mockPermissionDeleteMany = jest.fn<(args?: unknown) => unknown>();
|
||||
const mockPermissionCreate = jest.fn<(args?: unknown) => unknown>();
|
||||
const mock$transaction = jest.fn<(ops: unknown[], options?: unknown) => unknown>();
|
||||
const mockContentModelFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockUserRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
|
||||
const mockUserCount = jest.fn<(args?: unknown) => Promise<number>>();
|
||||
const mockRoleFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
@@ -27,6 +29,10 @@ jest.mock('@/lib/db', () => ({
|
||||
userRole: {
|
||||
findMany: mockUserRoleFindMany,
|
||||
},
|
||||
user: {
|
||||
count: mockUserCount,
|
||||
},
|
||||
$transaction: mock$transaction,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -66,9 +72,29 @@ const mockPermissions = [
|
||||
|
||||
function mockAuthenticated(roleCodes: string[]) {
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'admin' });
|
||||
mockUserCount.mockResolvedValue(1); // 默认「启用」账号(Q-4 存活判定)
|
||||
mockUserRoleFindMany.mockResolvedValue(roleCodes.map((code) => ({ roleCode: code })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma 的 PrismaPromise 是「惰性 + 可 then」的对象。这里用同样的形状冒充,
|
||||
* 一旦被 await(= 独立提交)就登记标签,从而能断言
|
||||
* 「deleteMany/create 只作为事务数组的成员交给 $transaction,没有被单独提交」。
|
||||
*/
|
||||
function thenableOp<T>(label: string, value: T, awaited: string[]) {
|
||||
return {
|
||||
then: jest.fn(
|
||||
(
|
||||
onFulfilled?: ((v: T) => unknown) | null,
|
||||
_onRejected?: ((e: unknown) => unknown) | null
|
||||
) => {
|
||||
awaited.push(label);
|
||||
return Promise.resolve(onFulfilled ? onFulfilled(value) : undefined);
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -170,8 +196,17 @@ describe('PUT /api/admin/roles', () => {
|
||||
it('replaces permissions and filters invalid actions', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindUnique.mockResolvedValue({ code: 'content_editor', name: '内容编辑' });
|
||||
mockPermissionDeleteMany.mockResolvedValue({ count: 2 });
|
||||
mockPermissionCreate.mockResolvedValue({});
|
||||
|
||||
const awaited: string[] = [];
|
||||
const deleteOp = thenableOp('deleteMany', { count: 2 }, awaited);
|
||||
const createOps = [
|
||||
thenableOp('create:news:read', { id: 'p1' }, awaited),
|
||||
thenableOp('create:news:update', { id: 'p2' }, awaited),
|
||||
];
|
||||
mockPermissionDeleteMany.mockReturnValue(deleteOp);
|
||||
let created = 0;
|
||||
mockPermissionCreate.mockImplementation(() => createOps[created++]);
|
||||
mock$transaction.mockReturnValue(thenableOp('transaction', [], awaited));
|
||||
|
||||
const response = await PUT(
|
||||
createMockRequest({
|
||||
@@ -181,6 +216,7 @@ describe('PUT /api/admin/roles', () => {
|
||||
{ modelCode: 'news', action: 'update' },
|
||||
{ modelCode: 'news', action: 'hack' },
|
||||
{ modelCode: '', action: 'read' },
|
||||
{ modelCode: 'news', action: 'read' }, // 重复项应被去重
|
||||
],
|
||||
})
|
||||
);
|
||||
@@ -203,5 +239,95 @@ describe('PUT /api/admin/roles', () => {
|
||||
expect(body.permissions).toContain('news:read');
|
||||
expect(body.permissions).toContain('news:update');
|
||||
expect(body.permissions).not.toContain('news:hack');
|
||||
|
||||
// 删除 + 重建作为「一个原子单元」交给 $transaction:数组顺序即执行顺序
|
||||
expect(mock$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mock$transaction.mock.calls[0]![0]).toEqual([deleteOp, createOps[0], createOps[1]]);
|
||||
// 除事务本身外,没有任何一条语句被独立 await(= 独立提交)
|
||||
expect(awaited).toEqual(['transaction']);
|
||||
});
|
||||
|
||||
it('commits the rewrite through $transaction so a failing create cannot leave a half-written role', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindUnique.mockResolvedValue({ code: 'content_editor', name: '内容编辑' });
|
||||
|
||||
const awaited: string[] = [];
|
||||
const deleteOp = thenableOp('deleteMany', { count: 5 }, awaited);
|
||||
const createOps = [
|
||||
thenableOp('create:news:read', { id: 'p1' }, awaited),
|
||||
thenableOp('create:product:read', { id: 'p2' }, awaited),
|
||||
];
|
||||
mockPermissionDeleteMany.mockReturnValue(deleteOp);
|
||||
let created = 0;
|
||||
mockPermissionCreate.mockImplementation(() => createOps[created++]);
|
||||
// 第 N 条 create 失败:事务整体回滚,$transaction 直接 reject
|
||||
mock$transaction.mockImplementation(() =>
|
||||
Promise.reject(new Error('Unique constraint failed on Permission'))
|
||||
);
|
||||
|
||||
const response = await PUT(
|
||||
createMockRequest({
|
||||
roleCode: 'content_editor',
|
||||
permissions: [
|
||||
{ modelCode: 'news', action: 'read' },
|
||||
{ modelCode: 'product', action: 'read' },
|
||||
],
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(body.code).toBe('INTERNAL_ERROR');
|
||||
// 失败前没有任何语句被独立提交:整批操作要么全成、要么全滚
|
||||
expect(awaited).toEqual([]);
|
||||
expect(mock$transaction.mock.calls[0]![0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('clears every permission through a single transaction when the new list is empty', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockRoleFindUnique.mockResolvedValue({ code: 'readonly', name: '只读' });
|
||||
|
||||
const awaited: string[] = [];
|
||||
const deleteOp = thenableOp('deleteMany', { count: 3 }, awaited);
|
||||
mockPermissionDeleteMany.mockReturnValue(deleteOp);
|
||||
mock$transaction.mockReturnValue(thenableOp('transaction', [], awaited));
|
||||
|
||||
const response = await PUT(createMockRequest({ roleCode: 'readonly', permissions: [] }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPermissionCreate).not.toHaveBeenCalled();
|
||||
expect(mock$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mock$transaction.mock.calls[0]![0]).toEqual([deleteOp]);
|
||||
expect(awaited).toEqual(['transaction']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Q-4(N-20 同类):roles GET/PUT 走内联 super_admin 检查但不看 User.status,
|
||||
* 被停用的超管凭 ≤24h 令牌仍能读权限矩阵或整体重写它(deleteMany+create)。
|
||||
* 修复后须先过 authenticateActiveRequest 的存活判定,否则 401 且不触达任何写。
|
||||
*/
|
||||
describe('Q-4 停用账号令牌:admin/roles 一律 401 且零写入', () => {
|
||||
it('停用的 super_admin 不能读取角色权限', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockUserCount.mockResolvedValue(0); // status !== 1
|
||||
|
||||
const response = await GET(createMockRequest());
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('停用的 super_admin 不能重写权限矩阵', async () => {
|
||||
mockAuthenticated(['super_admin']);
|
||||
mockUserCount.mockResolvedValue(0);
|
||||
|
||||
const response = await PUT(
|
||||
createMockRequest({ roleCode: 'content_editor', permissions: [{ modelCode: 'news', action: 'read' }] })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mock$transaction).not.toHaveBeenCalled();
|
||||
expect(mockPermissionDeleteMany).not.toHaveBeenCalled();
|
||||
expect(mockPermissionCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import type { Prisma } from '@/generated/prisma/client';
|
||||
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
|
||||
import { authenticateActiveRequest } from '@/lib/permissions';
|
||||
import { withCrypto } from '@/lib/api-crypto';
|
||||
|
||||
// 内置角色,不允许删除
|
||||
@@ -9,7 +10,7 @@ const BUILTIN_ROLES = new Set(['super_admin', 'content_admin', 'content_editor',
|
||||
|
||||
// GET /api/admin/roles - 获取角色列表及其权限
|
||||
export const GET = withCrypto(async (request: NextRequest) => {
|
||||
const user = authenticateRequest(request);
|
||||
const user = await authenticateActiveRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
// 仅管理员可查看角色权限
|
||||
@@ -51,7 +52,7 @@ export const GET = withCrypto(async (request: NextRequest) => {
|
||||
|
||||
// PUT /api/admin/roles/:roleCode - 更新角色权限
|
||||
export const PUT = 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 } });
|
||||
@@ -77,25 +78,35 @@ export const PUT = withCrypto(async (request: NextRequest) => {
|
||||
const role = await prisma.role.findUnique({ where: { code: roleCode } });
|
||||
if (!role) return validationError('角色不存在');
|
||||
|
||||
// 先删除该角色所有权限,再重新写入
|
||||
await prisma.permission.deleteMany({ where: { roleCode } });
|
||||
|
||||
const validActions = new Set(['create', 'read', 'update', 'delete', 'publish']);
|
||||
const uniqueKeys = new Set<string>();
|
||||
const permissionWrites: Prisma.PrismaPromise<unknown>[] = [];
|
||||
for (const perm of permissions || []) {
|
||||
if (!perm.modelCode || !validActions.has(perm.action)) continue;
|
||||
const key = `${perm.modelCode}:${perm.action}`;
|
||||
if (uniqueKeys.has(key)) continue;
|
||||
uniqueKeys.add(key);
|
||||
await prisma.permission.create({
|
||||
data: {
|
||||
roleCode,
|
||||
modelCode: perm.modelCode,
|
||||
action: perm.action,
|
||||
},
|
||||
});
|
||||
// 只构造查询、不 await:交由下面的事务统一提交
|
||||
permissionWrites.push(
|
||||
prisma.permission.create({
|
||||
data: {
|
||||
roleCode,
|
||||
modelCode: perm.modelCode,
|
||||
action: perm.action,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 清空 + 重建必须作为「单个原子单元」提交(验收 B-5):
|
||||
// 原先 deleteMany 与逐条 create 分别 await,任一 create 失败即留下空权限/半权限角色
|
||||
// (既是降权也是提权方向)。$transaction(数组) 在 Prisma 中于同一事务内顺序执行,
|
||||
// 任一语句失败整体回滚。签名见 src/generated/prisma/internal/class.ts:200。
|
||||
await prisma.$transaction([
|
||||
prisma.permission.deleteMany({ where: { roleCode } }),
|
||||
...permissionWrites,
|
||||
]);
|
||||
|
||||
return success({ roleCode, permissions: Array.from(uniqueKeys) });
|
||||
} catch (error) {
|
||||
console.error('Update roles error:', error);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
import { success, unauthorized, internalError } from '@/lib/api-response';
|
||||
import { requirePermission } from '@/lib/permissions';
|
||||
import { success, internalError } from '@/lib/api-response';
|
||||
import { withCrypto } from '@/lib/api-crypto';
|
||||
|
||||
// GET /api/admin/stats - 获取仪表盘统计数据
|
||||
export const GET = withCrypto(async (request: NextRequest) => {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
// 原实现只验「有没有会话」,而本路由会返回跨全部模型的最近内容条目(title/modelCode/status),
|
||||
// 任何登录账号(含零权限角色)都能读到受保护草稿 ⇒ 升级为 content-model 读权限(N-19)。
|
||||
const permission = await requirePermission(request, 'content-model', 'read');
|
||||
if ('response' in permission) return permission.response;
|
||||
const user = permission.user;
|
||||
|
||||
try {
|
||||
const [modelCount, itemCount, zoneCount, userCount, pendingReviewCount, unreadCount] = await Promise.all([
|
||||
|
||||
@@ -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