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