From bfb71333a40ba6fb5b5db84b5278decdfab22387 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 15:43:01 +0300 Subject: [PATCH] test: add auth routes integration tests Made-with: Cursor --- tests/helpers/build-test-app.ts | 46 +++++ tests/integration/auth.routes.test.ts | 265 ++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 tests/helpers/build-test-app.ts create mode 100644 tests/integration/auth.routes.test.ts diff --git a/tests/helpers/build-test-app.ts b/tests/helpers/build-test-app.ts new file mode 100644 index 0000000..d2e6849 --- /dev/null +++ b/tests/helpers/build-test-app.ts @@ -0,0 +1,46 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import { AppError } from '../../src/utils/errors.js'; +import { authRoutes } from '../../src/routes/auth.js'; +import type { MockDb } from '../test-utils.js'; +import { createMockDb } from '../test-utils.js'; + +/** + * Build a minimal Fastify app for auth route integration tests. + * Uses mock db and rate limit options (no actual rate limiting). + */ +export async function buildAuthTestApp(mockDb?: MockDb): Promise { + const db = mockDb ?? createMockDb(); + + const app = Fastify({ + logger: false, + requestIdHeader: 'x-request-id', + requestIdLogLabel: 'requestId', + }); + + app.setErrorHandler((err: unknown, request, reply) => { + const error = err as Error & { statusCode?: number; validation?: unknown }; + if (err instanceof AppError) { + return reply.status(err.statusCode).send(err.toJSON()); + } + if (error.validation) { + return reply.status(422).send({ + error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details: error.validation }, + }); + } + return reply.status(500).send({ error: { code: 'INTERNAL_ERROR', message: error.message } }); + }); + + app.decorate('db', db); + app.decorate('rateLimitOptions', { + login: { max: 100, timeWindow: '1 minute' }, + register: { max: 100, timeWindow: '1 hour' }, + forgotPassword: { max: 100, timeWindow: '1 hour' }, + verifyEmail: { max: 100, timeWindow: '15 minutes' }, + apiAuthed: { max: 100, timeWindow: '1 minute' }, + apiGuest: { max: 100, timeWindow: '1 minute' }, + }); + + await app.register(authRoutes, { prefix: '/auth' }); + + return app; +} diff --git a/tests/integration/auth.routes.test.ts b/tests/integration/auth.routes.test.ts new file mode 100644 index 0000000..a61c944 --- /dev/null +++ b/tests/integration/auth.routes.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { buildAuthTestApp } from '../helpers/build-test-app.js'; +import { + createMockDb, + selectChain, + insertChain, + updateChain, + deleteChain, +} from '../test-utils.js'; + +vi.mock('../../src/utils/password.js', () => ({ + hashPassword: vi.fn().mockResolvedValue('hashed-password'), + verifyPassword: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../../src/utils/jwt.js', () => ({ + signAccessToken: vi.fn().mockResolvedValue('access-token'), + signRefreshToken: vi.fn().mockResolvedValue('refresh-token'), + verifyToken: vi.fn(), + isRefreshPayload: vi.fn(), + hashToken: vi.fn((t: string) => `hash-${t}`), +})); + +vi.mock('node:crypto', () => ({ + randomBytes: vi.fn(() => ({ toString: () => 'abc123' })), + randomUUID: vi.fn(() => 'uuid-session-1'), +})); + +import { isRefreshPayload, verifyToken } from '../../src/utils/jwt.js'; + +describe('Auth routes integration', () => { + let app: Awaited>; + let mockDb: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + mockDb = createMockDb(); + app = await buildAuthTestApp(mockDb as never); + }); + + describe('POST /auth/register', () => { + it('returns 201 with userId and verificationCode when registration succeeds', async () => { + (mockDb.select as ReturnType) + .mockReturnValueOnce(selectChain([])) + .mockReturnValueOnce(selectChain([])); + (mockDb.insert as ReturnType) + .mockReturnValueOnce(insertChain([{ id: 'user-123' }])) + .mockReturnValueOnce(insertChain([])); + + const res = await app.inject({ + method: 'POST', + url: '/auth/register', + payload: { + email: 'test@example.com', + password: 'password123', + nickname: 'tester', + }, + }); + + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.userId).toBe('user-123'); + expect(body.message).toContain('verify your email'); + expect(body.verificationCode).toBeDefined(); + }); + + it('returns 409 when email is already taken', async () => { + (mockDb.select as ReturnType).mockReturnValueOnce( + selectChain([{ id: 'existing', email: 'test@example.com' }]) + ); + + const res = await app.inject({ + method: 'POST', + url: '/auth/register', + payload: { + email: 'test@example.com', + password: 'password123', + nickname: 'tester', + }, + }); + + expect(res.statusCode).toBe(409); + const body = JSON.parse(res.body); + expect(body.error?.code).toBe('EMAIL_TAKEN'); + }); + + it('returns 422 when validation fails', async () => { + const res = await app.inject({ + method: 'POST', + url: '/auth/register', + payload: { + email: 'short', + password: '123', // too short + nickname: 'x', // too short + }, + }); + + expect([400, 422]).toContain(res.statusCode); + const body = JSON.parse(res.body); + expect(body.error?.code ?? body.error).toBeDefined(); + }); + }); + + describe('POST /auth/login', () => { + it('returns tokens when credentials are valid', async () => { + const mockUser = { + id: 'user-1', + email: 'test@example.com', + passwordHash: 'hashed', + }; + (mockDb.select as ReturnType).mockReturnValueOnce( + selectChain([mockUser]) + ); + (mockDb.insert as ReturnType).mockReturnValueOnce( + insertChain([]) + ); + + const res = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: 'test@example.com', password: 'password123' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.accessToken).toBe('access-token'); + expect(body.refreshToken).toBe('refresh-token'); + }); + + it('returns 401 when credentials are invalid', async () => { + (mockDb.select as ReturnType).mockReturnValueOnce( + selectChain([]) + ); + + const res = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: 'unknown@example.com', password: 'wrong' }, + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /auth/logout', () => { + it('returns 204 on success', async () => { + (mockDb.delete as ReturnType).mockReturnValueOnce( + deleteChain() + ); + + const res = await app.inject({ + method: 'POST', + url: '/auth/logout', + payload: { refreshToken: 'some-token' }, + }); + + expect(res.statusCode).toBe(204); + }); + }); + + describe('POST /auth/refresh', () => { + it('returns new tokens when refresh token is valid', async () => { + vi.mocked(verifyToken).mockResolvedValueOnce({ + sub: 'user-1', + sid: 'sid-1', + type: 'refresh', + } as never); + vi.mocked(isRefreshPayload).mockReturnValueOnce(true); + (mockDb.select as ReturnType) + .mockReturnValueOnce(selectChain([{ id: 'sess-1', userId: 'user-1' }])) + .mockReturnValueOnce(selectChain([{ id: 'user-1', email: 'test@example.com' }])); + (mockDb.delete as ReturnType).mockReturnValueOnce( + deleteChain() + ); + (mockDb.insert as ReturnType).mockReturnValueOnce( + insertChain([]) + ); + + const res = await app.inject({ + method: 'POST', + url: '/auth/refresh', + payload: { refreshToken: 'valid-refresh-token' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.accessToken).toBe('access-token'); + expect(body.refreshToken).toBe('refresh-token'); + }); + }); + + describe('POST /auth/verify-email', () => { + it('returns success when code is valid', async () => { + const mockCode = { + id: 'code-1', + userId: 'user-1', + code: 'ABC123', + expiresAt: new Date(Date.now() + 60000), + }; + (mockDb.select as ReturnType) + .mockReturnValueOnce(selectChain([mockCode])) + .mockReturnValueOnce(selectChain([{ id: 'user-1', emailVerifiedAt: null }])); + (mockDb.update as ReturnType).mockReturnValueOnce( + updateChain([{ id: 'user-1' }]) + ); + (mockDb.delete as ReturnType).mockReturnValueOnce( + deleteChain() + ); + + const res = await app.inject({ + method: 'POST', + url: '/auth/verify-email', + payload: { userId: 'user-1', code: 'ABC123' }, + }); + + expect(res.statusCode).toBe(200); + }); + }); + + describe('POST /auth/forgot-password', () => { + it('returns 200 with generic message', async () => { + (mockDb.select as ReturnType).mockReturnValueOnce( + selectChain([{ id: 'user-1', email: 'test@example.com' }]) + ); + (mockDb.insert as ReturnType).mockReturnValueOnce( + insertChain([]) + ); + + const res = await app.inject({ + method: 'POST', + url: '/auth/forgot-password', + payload: { email: 'test@example.com' }, + }); + + expect(res.statusCode).toBe(200); + }); + }); + + describe('POST /auth/reset-password', () => { + it('returns 200 when token is valid', async () => { + const mockRecord = { + id: 'rec-1', + userId: 'user-1', + expiresAt: new Date(Date.now() + 60000), + }; + (mockDb.select as ReturnType).mockReturnValueOnce( + selectChain([mockRecord]) + ); + (mockDb.update as ReturnType).mockReturnValueOnce( + updateChain([{ id: 'user-1' }]) + ); + (mockDb.delete as ReturnType).mockReturnValueOnce( + deleteChain() + ); + + const res = await app.inject({ + method: 'POST', + url: '/auth/reset-password', + payload: { token: 'valid-token', newPassword: 'newPassword123' }, + }); + + expect(res.statusCode).toBe(200); + }); + }); +});