Files
novalon-website/src/app/api/admin/items/[id]/workflow/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

189 lines
6.8 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 mockContentItemFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
const mockContentItemFindUniqueOrThrow = jest.fn<(args: unknown) => Promise<unknown>>();
jest.mock('@/lib/db', () => ({
prisma: {
contentItem: {
findUnique: mockContentItemFindUnique,
findUniqueOrThrow: mockContentItemFindUniqueOrThrow,
},
},
}));
// ─── Mock workflow service ────────────────────────────────────────────────
const mockSubmitForReview = jest.fn<(itemId: string, user: unknown) => Promise<unknown>>();
const mockApprove = jest.fn<(itemId: string, user: unknown) => Promise<unknown>>();
const mockReject = jest.fn<(itemId: string, user: unknown, reason?: string) => Promise<unknown>>();
const mockArchive = jest.fn<(itemId: string, user: unknown) => Promise<unknown>>();
jest.mock('@/lib/cms/workflow', () => ({
submitForReview: mockSubmitForReview,
approve: mockApprove,
reject: mockReject,
archive: mockArchive,
}));
// ─── Mock permissions ─────────────────────────────────────────────────────
const mockRequirePermission = jest.fn<
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
>();
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';
function createMockRequest(body: Record<string, unknown>): NextRequest {
return {
headers: new Headers(),
url: 'http://localhost:3000/api/admin/items/item-1/workflow',
json: async () => body,
} as unknown as NextRequest;
}
function mockAuthorized(action: 'update' | 'publish' = 'update') {
mockRequirePermission.mockResolvedValue({
user: { userId: 'user-1', username: 'editor', role: action === 'publish' ? 'reviewer' : 'editor' },
});
}
const mockItem = {
id: 'item-1',
modelId: 'model-1',
modelCode: 'news',
title: '测试新闻',
slug: 'test-news',
locale: 'zh-CN',
status: 'draft',
data: '{}',
version: 1,
sortOrder: 0,
createdBy: 'editor',
updatedBy: 'editor',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockUpdatedItem = { ...mockItem, status: 'review', version: 2 };
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', () => {
it('returns 400 when action is missing', async () => {
mockAuthorized();
const request = createMockRequest({});
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('缺少 action 参数');
});
it('returns 400 for invalid action', async () => {
mockAuthorized();
const request = createMockRequest({ action: 'publish' });
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('非法的 action');
});
it('returns 404 when item not found', async () => {
mockAuthorized();
mockContentItemFindUnique.mockResolvedValue(null);
const request = createMockRequest({ action: 'submit' });
const response = await POST(request, { params: Promise.resolve({ id: 'missing' }) });
const body = await response.json();
expect(response.status).toBe(404);
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' });
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
expect(response.status).toBe(200);
expect(mockSubmitForReview).toHaveBeenCalledWith(
'item-1',
expect.objectContaining({ username: 'editor' })
);
expect(mockRequirePermission).toHaveBeenCalledWith(
request,
'news',
'update'
);
});
it('approve requires publish permission', async () => {
mockAuthorized('publish');
const request = createMockRequest({ action: 'approve' });
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
expect(response.status).toBe(200);
expect(mockApprove).toHaveBeenCalledWith(
'item-1',
expect.objectContaining({ username: 'editor' })
);
expect(mockRequirePermission).toHaveBeenCalledWith(
request,
'news',
'publish'
);
});
it('reject passes reason to workflow service', async () => {
mockAuthorized('publish');
const request = createMockRequest({ action: 'reject', reason: '需要修改' });
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
expect(response.status).toBe(200);
expect(mockReject).toHaveBeenCalledWith(
'item-1',
expect.objectContaining({ username: 'editor' }),
'需要修改'
);
});
it('returns validation error on illegal transition', async () => {
mockAuthorized('publish');
mockApprove.mockRejectedValue(new Error('当前状态 draft 不允许审核通过'));
const request = createMockRequest({ action: 'approve' });
const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) });
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('当前状态 draft 不允许审核通过');
});
});