安全/鉴权:登出清服务端 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 真库集成层。
189 lines
6.8 KiB
TypeScript
189 lines
6.8 KiB
TypeScript
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 不允许审核通过');
|
||
});
|
||
});
|