import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('../../../server/models/member.js', () => ({ default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('jsonwebtoken', () => ({ default: { verify: vi.fn(), sign: vi.fn().mockReturnValue('mock-session-token') } })) import jwt from 'jsonwebtoken' import Member from '../../../server/models/member.js' import verifyHandler from '../../../server/api/auth/verify.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' const baseMember = { _id: 'member-123', email: 'test@example.com', status: 'active', role: 'member', magicLinkJti: 'jti-abc', magicLinkJtiUsed: false, tokenVersion: 1 } describe('auth verify endpoint', () => { beforeEach(() => { vi.clearAllMocks() }) it('rejects missing token with 400', async () => { const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: {} }) await expect(verifyHandler(event)).rejects.toMatchObject({ statusCode: 400, statusMessage: 'Token is required' }) }) it('rejects invalid JWT with 401', async () => { jwt.verify.mockImplementation(() => { throw new Error('invalid') }) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'bad-token' } }) await expect(verifyHandler(event)).rejects.toMatchObject({ statusCode: 401, statusMessage: 'Invalid or expired token' }) }) it('rejects when member not found with 401', async () => { jwt.verify.mockReturnValue({ memberId: 'nonexistent', jti: 'jti-abc' }) Member.findById.mockResolvedValue(null) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) await expect(verifyHandler(event)).rejects.toMatchObject({ statusCode: 401, statusMessage: 'Invalid or expired token' }) }) it('rejects suspended member with 403', async () => { jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' }) Member.findById.mockResolvedValue({ ...baseMember, status: 'suspended' }) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) await expect(verifyHandler(event)).rejects.toMatchObject({ statusCode: 403, statusMessage: 'Account is suspended' }) }) it('rejects cancelled member with 403', async () => { jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' }) Member.findById.mockResolvedValue({ ...baseMember, status: 'cancelled' }) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) await expect(verifyHandler(event)).rejects.toMatchObject({ statusCode: 403, statusMessage: 'Account is cancelled' }) }) it('rejects JTI mismatch with 401', async () => { jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'wrong-jti' }) Member.findById.mockResolvedValue({ ...baseMember }) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) await expect(verifyHandler(event)).rejects.toMatchObject({ statusCode: 401, statusMessage: 'Invalid or expired token' }) }) it('rejects already-used JTI with 401', async () => { jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' }) Member.findById.mockResolvedValue({ ...baseMember, magicLinkJtiUsed: true }) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) await expect(verifyHandler(event)).rejects.toMatchObject({ statusCode: 401, statusMessage: 'Invalid or expired token' }) }) it('burns token atomically via findByIdAndUpdate', async () => { jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' }) Member.findById.mockResolvedValue({ ...baseMember }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) await verifyHandler(event) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-123', { $set: { magicLinkJtiUsed: true, lastLogin: expect.any(Date) } }, { runValidators: false } ) }) it('sets httpOnly session cookie with correct attributes', async () => { jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' }) Member.findById.mockResolvedValue({ ...baseMember }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) await verifyHandler(event) const setCookieHeader = event._testSetHeaders['set-cookie'] expect(setCookieHeader).toBeDefined() const cookie = Array.isArray(setCookieHeader) ? setCookieHeader.join('; ') : setCookieHeader expect(cookie).toContain('auth-token=mock-session-token') expect(cookie).toContain('HttpOnly') expect(cookie).toContain('SameSite=Lax') expect(cookie).toContain('Path=/') expect(cookie).toContain('Max-Age=604800') }) it('returns admin redirect for admin role', async () => { jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' }) Member.findById.mockResolvedValue({ ...baseMember, role: 'admin' }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) const result = await verifyHandler(event) expect(result).toEqual({ success: true, redirectUrl: '/admin' }) }) it('returns member redirect for non-admin role', async () => { jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' }) Member.findById.mockResolvedValue({ ...baseMember }) Member.findByIdAndUpdate.mockResolvedValue({}) const event = createMockEvent({ method: 'POST', path: '/api/auth/verify', body: { token: 'valid-token' } }) const result = await verifyHandler(event) expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' }) }) })