Files
novalon-website/src/app/api/admin/items/route.test.ts
T
zhangxiang a366bd1400 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 真库集成层。
2026-09-28 10:48:07 +08:00

632 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
const mockContentItemFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
const mockContentItemCount = jest.fn<(args: unknown) => Promise<number>>();
const mockContentItemFindFirst = jest.fn<(args: unknown) => Promise<unknown | null>>();
const mockContentItemFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
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: {
contentItem: {
findMany: mockContentItemFindMany,
count: mockContentItemCount,
findFirst: mockContentItemFindFirst,
findUnique: mockContentItemFindUnique,
create: mockContentItemCreate,
update: mockContentItemUpdate,
delete: mockContentItemDelete,
},
contentModel: {
findUnique: mockContentModelFindUnique,
},
auditLog: {
create: mockAuditLogCreate,
},
},
}));
// ─── Mock permissions ─────────────────────────────────────────────────────
const mockRequirePermission = jest.fn<
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
>();
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;
json?: () => Promise<Record<string, unknown>>;
}): NextRequest {
return {
headers: new Headers(),
url: options.url || 'http://localhost/api/admin/items',
json: options.json || (async () => ({})),
} as unknown as NextRequest;
}
function mockAuthorized() {
mockRequirePermission.mockResolvedValue({
user: { userId: 'user-1', username: 'editor', role: 'content_editor' },
});
}
function mockUnauthorized() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
});
}
function mockForbidden() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
});
}
const mockItem = {
id: 'item-1',
modelId: 'model-1',
modelCode: 'news',
title: '测试新闻',
slug: 'test-news',
locale: 'zh-CN',
status: 'draft',
data: JSON.stringify({ body: 'hello' }),
version: 1,
sortOrder: 0,
createdBy: 'editor',
updatedBy: 'editor',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
publishedAt: null,
};
beforeEach(() => {
jest.clearAllMocks();
mockContentItemFindMany.mockResolvedValue([mockItem]);
mockContentItemCount.mockResolvedValue(1);
mockContentItemFindFirst.mockResolvedValue(null);
mockContentItemFindUnique.mockResolvedValue(mockItem);
mockContentItemCreate.mockResolvedValue(mockItem);
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', () => {
describe('GET', () => {
it('returns 401 when not authenticated', async () => {
mockUnauthorized();
const response = await GET(createMockRequest({}));
expect(response.status).toBe(401);
});
it('returns 403 when authenticated but unauthorized', async () => {
mockForbidden();
const response = await GET(createMockRequest({}));
expect(response.status).toBe(403);
});
it('returns paginated item list', async () => {
mockAuthorized();
const response = await GET(createMockRequest({ url: 'http://localhost/api/admin/items?page=1&pageSize=10' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.items).toHaveLength(1);
expect(body.total).toBe(1);
expect(body.page).toBe(1);
expect(body.totalPages).toBe(1);
expect(body.items[0]).toMatchObject({
id: 'item-1',
title: '测试新闻',
data: { body: 'hello' },
});
});
// 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' }));
expect(mockContentItemFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { modelCode: 'news', status: 'published' },
})
);
expect(mockRequirePermission).toHaveBeenCalledWith(
expect.anything(),
'news',
'read'
);
});
it('applies search filter', async () => {
mockAuthorized();
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?search=测试' }));
expect(mockContentItemFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [
{ title: { contains: '测试' } },
{ slug: { contains: '测试' } },
],
}),
})
);
});
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', () => {
it('returns 401 when not authenticated', async () => {
mockUnauthorized();
const response = await POST(createMockRequest({
json: async () => ({ modelId: 'model-1', modelCode: 'news', title: '新闻' }),
}));
expect(response.status).toBe(401);
});
it('returns 400 when required fields missing', async () => {
mockAuthorized();
const response = await POST(createMockRequest({ json: async () => ({ title: '仅标题' }) }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('缺少必填字段');
});
it('returns 400 when slug already exists', async () => {
mockAuthorized();
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'test-news' });
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'news',
title: '新新闻',
slug: 'test-news',
}),
}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('已存在');
});
it('creates item as draft and writes audit log', async () => {
mockAuthorized();
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'news',
title: '新新闻',
slug: 'new-news',
data: { body: 'content' },
sortOrder: 1,
}),
}));
const body = await response.json();
expect(response.status).toBe(201);
expect(body.title).toBe('测试新闻');
expect(mockContentItemCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
modelId: 'model-1',
modelCode: 'news',
title: '新新闻',
slug: 'new-news',
status: 'draft',
sortOrder: 1,
publishedAt: null,
}),
})
);
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'item',
action: 'create',
operator: 'editor',
}),
})
);
});
// 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', () => {
it('returns 400 when id missing', async () => {
mockAuthorized();
const response = await PUT(createMockRequest({ url: 'http://localhost/api/admin/items', json: async () => ({}) }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('缺少 ID');
});
it('returns 404 when item not found', async () => {
mockAuthorized();
mockContentItemFindUnique.mockResolvedValue(null);
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=missing',
json: async () => ({ title: '更新' }),
}));
expect(response.status).toBe(404);
});
it('rejects status update through PUT', async () => {
mockAuthorized();
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ status: 'published' }),
}));
const body = await response.json();
expect(response.status).toBe(400);
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' });
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ slug: 'duplicated' }),
}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('已存在');
});
it('updates item and increments version', async () => {
mockAuthorized();
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ title: '更新标题', data: { body: 'new' }, sortOrder: 2 }),
}));
await response.json();
expect(response.status).toBe(200);
expect(mockContentItemUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'item-1' },
data: expect.objectContaining({
title: '更新标题',
data: JSON.stringify({ body: 'new' }),
sortOrder: 2,
version: { increment: 1 },
updatedBy: 'editor',
}),
})
);
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'item',
action: 'update',
operator: 'editor',
}),
})
);
});
});
describe('DELETE', () => {
it('returns 400 when id missing', async () => {
mockAuthorized();
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items' }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('缺少 ID');
});
it('returns 404 when item not found', async () => {
mockAuthorized();
mockContentItemFindUnique.mockResolvedValue(null);
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items?id=missing' }));
expect(response.status).toBe(404);
});
it('deletes item and audit log', async () => {
mockAuthorized();
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.message).toBe('删除成功');
expect(mockContentItemDelete).toHaveBeenCalledWith({ where: { id: 'item-1' } });
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'item',
action: 'delete',
operator: 'editor',
}),
})
);
});
});
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: '{}' }),
})
);
});
});
});