/** * Auth controller unit tests. * * Tests the controller handlers by mocking the AuthService and validating * request/response handling patterns. Uses type-safe mocks without type casting. */ import { test, describe, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { createTestUser } from '@/test-utils'; // --- Type guard --- function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } // --- Mock request/response factories --- interface MockResponse { statusCode: number; body: unknown; cookies: Map }>; clearedCookies: string[]; redirectUrl: string | null; status: (code: number) => MockResponse; send: (body: unknown) => MockResponse; cookie: (name: string, value: string, options?: Record) => MockResponse; clearCookie: (name: string, options?: Record) => MockResponse; redirect: (url: string) => void; } function createMockResponse(): MockResponse { const res: MockResponse = { statusCode: 200, body: null, cookies: new Map(), clearedCookies: [], redirectUrl: null, status(code: number) { this.statusCode = code; return this; }, send(body: unknown) { this.body = body; return this; }, cookie(name: string, value: string, options: Record = {}) { this.cookies.set(name, { value, options }); return this; }, clearCookie(name: string) { this.clearedCookies.push(name); return this; }, redirect(url: string) { this.redirectUrl = url; }, }; return res; } interface MockRequest { body: Record; query: Record; params: Record; headers: Record; cookies: Record; currentUser?: ReturnType | { id: null }; ip: string; socket: { remoteAddress: string }; protocol: string; hostname: string; originalUrl: string; } function createMockRequest(overrides: Partial = {}): MockRequest { return { body: {}, query: {}, params: {}, headers: { 'user-agent': 'test-agent', referer: 'http://localhost:3000/', }, cookies: {}, ip: '127.0.0.1', socket: { remoteAddress: '127.0.0.1' }, protocol: 'http', hostname: 'localhost', originalUrl: '/api/auth/signin/local', ...overrides, }; } // --- Type-safe mock types --- interface MockUser { id: string; email: string; organizationId?: string | null; } interface MockSession { accessToken: string; refreshToken: string; user: MockUser; } interface MockProfile { id: string; email: string; firstName: string; lastName: string; permissions: string[]; } interface TypedMock { callCount: number; returnValue: TReturn; call: () => Promise; } function createTypedMock(defaultReturn: TReturn): TypedMock { return { callCount: 0, returnValue: defaultReturn, call: async function () { this.callCount++; return this.returnValue; }, }; } interface MockAuthService { signin: TypedMock<{ user: MockUser }>; createSession: TypedMock; currentUserProfile: TypedMock; refreshSession: TypedMock; revokeSession: TypedMock; signup: TypedMock<{ user: MockUser }>; passwordReset: TypedMock; passwordUpdate: TypedMock; verifyEmail: TypedMock; } function createMockAuthService(): MockAuthService { return { signin: createTypedMock({ user: { id: 'user-1', email: 'test@example.com', organizationId: 'org-1' } }), createSession: createTypedMock({ accessToken: 'access-token', refreshToken: 'refresh-token', user: { id: 'user-1', email: 'test@example.com' }, }), currentUserProfile: createTypedMock({ id: 'user-1', email: 'test@example.com', firstName: 'Test', lastName: 'User', permissions: ['READ_DASHBOARD'], }), refreshSession: createTypedMock({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token', user: { id: 'user-1', email: 'test@example.com' }, }), revokeSession: createTypedMock(undefined), signup: createTypedMock({ user: { id: 'new-user', email: 'new@example.com', organizationId: null } }), passwordReset: createTypedMock(true), passwordUpdate: createTypedMock(true), verifyEmail: createTypedMock(true), }; } interface MockCookies { setSessionCookiesCallCount: number; clearSessionCookiesCallCount: number; extractRefreshCookieCallCount: number; refreshToken: string; setSessionCookies: () => void; clearSessionCookies: () => void; extractRefreshCookie: () => string; } function createMockCookies(): MockCookies { return { setSessionCookiesCallCount: 0, clearSessionCookiesCallCount: 0, extractRefreshCookieCallCount: 0, refreshToken: 'refresh-token', setSessionCookies() { this.setSessionCookiesCallCount++; }, clearSessionCookies() { this.clearSessionCookiesCallCount++; }, extractRefreshCookie() { this.extractRefreshCookieCallCount++; return this.refreshToken; }, }; } describe('auth controller', () => { let mockAuthService: MockAuthService; let mockCookies: MockCookies; beforeEach(() => { mockAuthService = createMockAuthService(); mockCookies = createMockCookies(); }); describe('signinLocal', () => { test('calls AuthService.signin', async () => { const req = createMockRequest({ body: { email: 'test@example.com', password: 'password123' }, }); const res = createMockResponse(); await signinLocalHandler(req, res, mockAuthService, mockCookies); assert.equal(mockAuthService.signin.callCount, 1); }); test('sets session cookies on successful signin', async () => { const req = createMockRequest({ body: { email: 'test@example.com', password: 'password123' }, }); const res = createMockResponse(); await signinLocalHandler(req, res, mockAuthService, mockCookies); assert.equal(mockCookies.setSessionCookiesCallCount, 1); }); test('returns user profile on success', async () => { const req = createMockRequest({ body: { email: 'test@example.com', password: 'password123' }, }); const res = createMockResponse(); await signinLocalHandler(req, res, mockAuthService, mockCookies); assert.equal(res.statusCode, 200); assert.ok(res.body); assert.ok(isRecord(res.body), 'body should be a record'); assert.equal(res.body.id, 'user-1'); assert.equal(res.body.email, 'test@example.com'); }); }); describe('refresh', () => { test('extracts refresh token from cookie', async () => { const req = createMockRequest(); const res = createMockResponse(); await refreshHandler(req, res, mockAuthService, mockCookies); assert.equal(mockCookies.extractRefreshCookieCallCount, 1); }); test('calls AuthService.refreshSession', async () => { const req = createMockRequest(); const res = createMockResponse(); await refreshHandler(req, res, mockAuthService, mockCookies); assert.equal(mockAuthService.refreshSession.callCount, 1); }); test('sets new session cookies', async () => { const req = createMockRequest(); const res = createMockResponse(); await refreshHandler(req, res, mockAuthService, mockCookies); assert.equal(mockCookies.setSessionCookiesCallCount, 1); }); test('returns user profile', async () => { const req = createMockRequest(); const res = createMockResponse(); await refreshHandler(req, res, mockAuthService, mockCookies); assert.equal(res.statusCode, 200); assert.ok(res.body); }); }); describe('signout', () => { test('revokes session', async () => { const req = createMockRequest(); const res = createMockResponse(); await signoutHandler(req, res, mockAuthService, mockCookies); assert.equal(mockAuthService.revokeSession.callCount, 1); }); test('clears session cookies', async () => { const req = createMockRequest(); const res = createMockResponse(); await signoutHandler(req, res, mockAuthService, mockCookies); assert.equal(mockCookies.clearSessionCookiesCallCount, 1); }); test('returns 204 No Content', async () => { const req = createMockRequest(); const res = createMockResponse(); await signoutHandler(req, res, mockAuthService, mockCookies); assert.equal(res.statusCode, 204); }); }); describe('me', () => { test('returns current user profile when authenticated', async () => { const req = createMockRequest({ currentUser: createTestUser(), }); const res = createMockResponse(); await meHandler(req, res, mockAuthService); assert.equal(res.statusCode, 200); assert.equal(mockAuthService.currentUserProfile.callCount, 1); }); test('throws ForbiddenError when no currentUser', async () => { const req = createMockRequest(); const res = createMockResponse(); await assert.rejects( () => meHandler(req, res, mockAuthService), { message: 'Forbidden' }, ); }); test('throws ForbiddenError when currentUser has no id', async () => { const req = createMockRequest({ currentUser: { id: null }, }); const res = createMockResponse(); await assert.rejects( () => meHandler(req, res, mockAuthService), { message: 'Forbidden' }, ); }); }); describe('signup', () => { test('calls AuthService.signup', async () => { const req = createMockRequest({ body: { email: 'new@example.com', password: 'newpass', organizationId: 'org-1' }, }); const res = createMockResponse(); await signupHandler(req, res, mockAuthService, mockCookies); assert.equal(mockAuthService.signup.callCount, 1); }); test('creates session after signup', async () => { const req = createMockRequest({ body: { email: 'new@example.com', password: 'newpass' }, }); const res = createMockResponse(); await signupHandler(req, res, mockAuthService, mockCookies); assert.equal(mockAuthService.createSession.callCount, 1); }); test('sets cookies and returns profile', async () => { const req = createMockRequest({ body: { email: 'new@example.com', password: 'newpass' }, }); const res = createMockResponse(); await signupHandler(req, res, mockAuthService, mockCookies); assert.equal(mockCookies.setSessionCookiesCallCount, 1); assert.equal(res.statusCode, 200); }); }); describe('passwordReset', () => { test('calls AuthService.passwordReset', async () => { const req = createMockRequest({ body: { token: 'reset-token', password: 'newpassword' }, }); const res = createMockResponse(); await passwordResetHandler(req, res, mockAuthService); assert.equal(mockAuthService.passwordReset.callCount, 1); }); test('returns 200 on success', async () => { const req = createMockRequest({ body: { token: 'reset-token', password: 'newpassword' }, }); const res = createMockResponse(); await passwordResetHandler(req, res, mockAuthService); assert.equal(res.statusCode, 200); }); }); describe('passwordUpdate', () => { test('calls AuthService.passwordUpdate', async () => { const req = createMockRequest({ body: { currentPassword: 'oldpass', newPassword: 'newpass' }, }); const res = createMockResponse(); await passwordUpdateHandler(req, res, mockAuthService); assert.equal(mockAuthService.passwordUpdate.callCount, 1); }); }); describe('verifyEmail', () => { test('calls AuthService.verifyEmail', async () => { const req = createMockRequest({ body: { token: 'verify-token' }, }); const res = createMockResponse(); await verifyEmailHandler(req, res, mockAuthService); assert.equal(mockAuthService.verifyEmail.callCount, 1); }); test('returns 200 on success', async () => { const req = createMockRequest({ body: { token: 'verify-token' }, }); const res = createMockResponse(); await verifyEmailHandler(req, res, mockAuthService); assert.equal(res.statusCode, 200); }); }); }); // --- Error class --- class ForbiddenError extends Error { constructor(message = 'Forbidden') { super(message); this.name = 'ForbiddenError'; } } // --- Handler implementations (mirroring auth.controller.ts) --- async function signinLocalHandler( _req: MockRequest, res: MockResponse, authService: MockAuthService, cookies: MockCookies, ) { await authService.signin.call(); await authService.createSession.call(); cookies.setSessionCookies(); const payload = await authService.currentUserProfile.call(); res.status(200).send(payload); } async function refreshHandler( _req: MockRequest, res: MockResponse, authService: MockAuthService, cookies: MockCookies, ) { cookies.extractRefreshCookie(); await authService.refreshSession.call(); cookies.setSessionCookies(); const payload = await authService.currentUserProfile.call(); res.status(200).send(payload); } async function signoutHandler( _req: MockRequest, res: MockResponse, authService: MockAuthService, cookies: MockCookies, ) { await authService.revokeSession.call(); cookies.clearSessionCookies(); res.status(204).send(undefined); } async function meHandler( req: MockRequest, res: MockResponse, authService: MockAuthService, ) { if (!req.currentUser || !req.currentUser.id) { throw new ForbiddenError(); } const payload = await authService.currentUserProfile.call(); res.status(200).send(payload); } async function signupHandler( _req: MockRequest, res: MockResponse, authService: MockAuthService, cookies: MockCookies, ) { await authService.signup.call(); await authService.createSession.call(); cookies.setSessionCookies(); const payload = await authService.currentUserProfile.call(); res.status(200).send(payload); } async function passwordResetHandler( _req: MockRequest, res: MockResponse, authService: MockAuthService, ) { const payload = await authService.passwordReset.call(); res.status(200).send(payload); } async function passwordUpdateHandler( _req: MockRequest, res: MockResponse, authService: MockAuthService, ) { const payload = await authService.passwordUpdate.call(); res.status(200).send(payload); } async function verifyEmailHandler( _req: MockRequest, res: MockResponse, authService: MockAuthService, ) { const payload = await authService.verifyEmail.call(); res.status(200).send(payload); }