import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import type { NextRequest } from 'next/server'; const mockUserFindUnique = jest.fn<(args: unknown) => Promise>(); const mockUserRoleFindMany = jest.fn<(args: unknown) => Promise>(); 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): 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(); }); });