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
+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: '{}' }),
})
);
});
});
});