Files
novalon-website/src/app/api/auth/refresh/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

159 lines
5.3 KiB
TypeScript

import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import type { NextRequest } from 'next/server';
const mockUserFindUnique = jest.fn<(args: unknown) => Promise<unknown>>();
const mockUserRoleFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
jest.mock('@/lib/db', () => ({
prisma: {
user: { findUnique: mockUserFindUnique },
userRole: { findMany: mockUserRoleFindMany },
},
}));
const mockVerifyRefreshToken = jest.fn<(token: string) => unknown>();
const mockGenerateTokens = jest.fn<(payload: unknown) => { accessToken: string; refreshToken: string }>();
const mockSetTokenCookie = jest.fn();
jest.mock('@/lib/auth', () => ({
verifyRefreshToken: (token: string) => mockVerifyRefreshToken(token),
generateTokens: (payload: unknown) => mockGenerateTokens(payload),
setTokenCookie: (r: unknown, a: string, b: string, s: boolean) => mockSetTokenCookie(r, a, b, s),
}));
jest.unmock('./route');
import { POST } from './route';
const legacyPayload = {
userId: 'user-1',
username: 'editor',
role: 'super_admin',
};
function createRequest(body: Record<string, unknown>): NextRequest {
return {
url: 'http://localhost:3000/api/auth/refresh',
nextUrl: { protocol: 'http:' },
headers: new Headers(),
json: async () => body,
} as unknown as NextRequest;
}
const request = () => createRequest({ refreshToken: 'signed-refresh-token' });
beforeEach(() => {
jest.clearAllMocks();
mockVerifyRefreshToken.mockReturnValue(legacyPayload);
mockUserFindUnique.mockResolvedValue({ id: 'user-1', username: 'editor', role: 'admin', status: 1 });
mockUserRoleFindMany.mockResolvedValue([{ userId: 'user-1', roleCode: 'super_admin' }]);
mockGenerateTokens.mockReturnValue({ accessToken: 'new-access', refreshToken: 'new-refresh' });
});
describe('POST /api/auth/refresh', () => {
it('returns 401 when the refresh token is missing', async () => {
const response = await POST(createRequest({}));
expect(response.status).toBe(401);
expect(mockGenerateTokens).not.toHaveBeenCalled();
});
it('returns 401 when the refresh token signature is invalid', async () => {
mockVerifyRefreshToken.mockImplementation(() => {
throw new Error('jwt expired');
});
const response = await POST(request());
expect(response.status).toBe(401);
expect(mockUserFindUnique).not.toHaveBeenCalled();
expect(mockGenerateTokens).not.toHaveBeenCalled();
});
it('mints no access token for a disabled user', async () => {
mockUserFindUnique.mockResolvedValue({ id: 'user-1', username: 'editor', status: 0 });
const response = await POST(request());
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
expect(mockGenerateTokens).not.toHaveBeenCalled();
expect(mockSetTokenCookie).not.toHaveBeenCalled();
expect(mockUserRoleFindMany).not.toHaveBeenCalled();
});
it('mints no access token for a deleted user', async () => {
mockUserFindUnique.mockResolvedValue(null);
const response = await POST(request());
expect(response.status).toBe(401);
expect(mockGenerateTokens).not.toHaveBeenCalled();
expect(mockSetTokenCookie).not.toHaveBeenCalled();
});
it('looks the account up by the token subject id, not by username', async () => {
await POST(request());
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { id: 'user-1' } });
expect(mockUserRoleFindMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
});
it('re-signs with the demoted role from the database instead of the stale claim', async () => {
mockUserRoleFindMany.mockResolvedValue([{ userId: 'user-1', roleCode: 'content_editor' }]);
const response = await POST(request());
const body = await response.json();
expect(response.status).toBe(200);
expect(mockGenerateTokens).toHaveBeenCalledWith({
userId: 'user-1',
username: 'editor',
role: 'content_editor',
});
expect(mockGenerateTokens.mock.calls[0]![0]).not.toMatchObject({ role: 'super_admin' });
expect(body).toEqual({ accessToken: 'new-access', refreshToken: 'new-refresh' });
});
it('falls back to the stored role column when the account carries no role grants', async () => {
mockUserRoleFindMany.mockResolvedValue([]);
const response = await POST(request());
expect(response.status).toBe(200);
expect(mockGenerateTokens).toHaveBeenCalledWith({
userId: 'user-1',
username: 'editor',
role: 'admin',
});
});
it('re-issues from the current account row rather than the token payload', async () => {
mockUserFindUnique.mockResolvedValue({
id: 'user-1',
username: 'renamed-editor',
role: 'content_editor',
status: 1,
});
mockUserRoleFindMany.mockResolvedValue([{ userId: 'user-1', roleCode: 'reviewer' }]);
await POST(request());
expect(mockGenerateTokens).toHaveBeenCalledWith({
userId: 'user-1',
username: 'renamed-editor',
role: 'reviewer',
});
});
it('returns 500 when the account lookup fails instead of handing out a token', async () => {
mockUserFindUnique.mockRejectedValue(new Error('SQLITE_BUSY'));
const response = await POST(request());
expect(response.status).toBe(500);
expect(mockGenerateTokens).not.toHaveBeenCalled();
});
});