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
@@ -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('内容不存在');
+273 -4
View File
@@ -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: '{}' }),
})
);
});
});
});
+68 -8
View File
@@ -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('内容不存在');
+37
View File
@@ -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', () => {
+33 -13
View File
@@ -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();
}
+130 -4
View File
@@ -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();
});
});
+24 -13
View File
@@ -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);
+7 -4
View File
@@ -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([
+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 } });