import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock prisma before importing authService vi.mock('../../prisma.js', () => ({ prisma: { session: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), deleteMany: vi.fn(), findMany: vi.fn() } } })); // Set JWT_SECRET for tests — must be ≥ 32 chars and not a known placeholder // (enforced by getJwtSecret in authService). process.env.JWT_SECRET = 'test-secret-key-for-unit-tests-must-be-at-least-32-chars-long'; import { hashPassword, verifyPassword, signAccessToken, verifyAccessToken, generateRefreshToken, createSession, rotateSession, validateSession } from '../authService.js'; import { prisma } from '../../prisma.js'; describe('authService', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('hashPassword / verifyPassword', () => { it('hashes a password and verifies it correctly', async () => { const password = 'mySecurePassword123'; const hash = await hashPassword(password); expect(hash).not.toBe(password); expect(hash.length).toBeGreaterThan(0); const isValid = await verifyPassword(password, hash); expect(isValid).toBe(true); }); it('rejects wrong password', async () => { const hash = await hashPassword('correct-password'); const isValid = await verifyPassword('wrong-password', hash); expect(isValid).toBe(false); }); }); describe('signAccessToken / verifyAccessToken', () => { it('signs and verifies a token', () => { const payload = { userId: 'usr-1', email: 'test@test.com', role: 'user' }; const token = signAccessToken(payload); expect(typeof token).toBe('string'); expect(token.split('.')).toHaveLength(3); const decoded = verifyAccessToken(token); expect(decoded.userId).toBe('usr-1'); expect(decoded.email).toBe('test@test.com'); expect(decoded.role).toBe('user'); }); it('throws for invalid token', () => { expect(() => verifyAccessToken('invalid.token.value')).toThrow( 'Invalid or expired access token' ); }); }); describe('generateRefreshToken', () => { it('generates a prefixed hex string', () => { const token = generateRefreshToken(); expect(typeof token).toBe('string'); // "rt_" prefix (3) + 48 bytes * 2 hex chars (96) = 99 expect(token.length).toBe(99); expect(token.startsWith('rt_')).toBe(true); expect(/^rt_[0-9a-f]+$/.test(token)).toBe(true); }); it('generates unique tokens', () => { const token1 = generateRefreshToken(); const token2 = generateRefreshToken(); expect(token1).not.toBe(token2); }); }); describe('createSession', () => { it('creates a session row and returns the raw refresh token', async () => { vi.mocked(prisma.session.create).mockResolvedValue({ id: 'ses-1', userId: 'usr-1', tokenHash: 'hash', label: 'Chrome on Windows', userAgent: 'ua', ipAddress: '127.0.0.1', rememberMe: false, lastUsedAt: new Date(), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), createdAt: new Date() } as never); const result = await createSession('usr-1', { userAgent: 'ua', ipAddress: '127.0.0.1' }); expect(result.sessionId).toBe('ses-1'); // Tokens are now prefixed with "rt_" (3 chars) + 96 hex chars = 99 expect(result.refreshToken.length).toBe(99); expect(result.refreshToken.startsWith('rt_')).toBe(true); expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now()); expect(prisma.session.create).toHaveBeenCalledTimes(1); }); it('extends expiry for remember-me sessions', async () => { vi.mocked(prisma.session.create).mockImplementation( (({ data }: { data: Record }) => Promise.resolve({ id: 'ses-2', ...data, lastUsedAt: new Date(), createdAt: new Date() })) as never ); const result = await createSession('usr-1', { rememberMe: true }); const diffDays = (result.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000); expect(diffDays).toBeGreaterThan(29); expect(diffDays).toBeLessThan(31); }); }); describe('validateSession', () => { it('returns null for missing session', async () => { vi.mocked(prisma.session.findUnique).mockResolvedValue(null); const result = await validateSession('ses-x', 'token'); expect(result).toBeNull(); }); it('returns null for expired session', async () => { vi.mocked(prisma.session.findUnique).mockResolvedValue({ id: 'ses-1', userId: 'usr-1', tokenHash: 'hash', rememberMe: false, expiresAt: new Date(Date.now() - 1000), lastUsedAt: new Date(), createdAt: new Date(), label: null, userAgent: null, ipAddress: null } as never); const result = await validateSession('ses-1', 'token'); expect(result).toBeNull(); }); }); describe('rotateSession', () => { it('updates token hash and keeps the same session id', async () => { vi.mocked(prisma.session.findUnique).mockResolvedValue({ id: 'ses-1', userId: 'usr-1', rememberMe: false, expiresAt: new Date(Date.now() + 1000) } as never); vi.mocked(prisma.session.update).mockResolvedValue({} as never); const result = await rotateSession('ses-1'); expect(result.sessionId).toBe('ses-1'); // Tokens are now prefixed with "rt_" (3 chars) + 96 hex chars = 99 expect(result.refreshToken.length).toBe(99); expect(result.refreshToken.startsWith('rt_')).toBe(true); expect(prisma.session.update).toHaveBeenCalledTimes(1); }); }); });