/** * Tests for chat OTP API route * * @vitest-environment node */ import { redisConfigMock, redisConfigMockFns, requestUtilsMockFns, workflowsApiUtilsMock, workflowsApiUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockRedisSet, mockRedisGet, mockRedisDel, mockRedisTtl, mockRedisEval, mockRedisClient, mockDbSelect, mockDbInsert, mockDbDelete, mockDbUpdate, mockSendEmail, mockRenderOTPEmail, mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, mockGetEnv, } = vi.hoisted(() => { const mockRedisSet = vi.fn() const mockRedisGet = vi.fn() const mockRedisDel = vi.fn() const mockRedisTtl = vi.fn() const mockRedisEval = vi.fn() const mockRedisClient = { set: mockRedisSet, get: mockRedisGet, del: mockRedisDel, ttl: mockRedisTtl, eval: mockRedisEval, } const mockDbSelect = vi.fn() const mockDbInsert = vi.fn() const mockDbDelete = vi.fn() const mockDbUpdate = vi.fn() const mockSendEmail = vi.fn() const mockRenderOTPEmail = vi.fn() const mockSetChatAuthCookie = vi.fn() const mockGetStorageMethod = vi.fn() const mockZodParse = vi.fn() const mockGetEnv = vi.fn() return { mockRedisSet, mockRedisGet, mockRedisDel, mockRedisTtl, mockRedisEval, mockRedisClient, mockDbSelect, mockDbInsert, mockDbDelete, mockDbUpdate, mockSendEmail, mockRenderOTPEmail, mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, mockGetEnv, } }) const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse vi.mock('@/lib/core/config/redis', () => redisConfigMock) vi.mock('@sim/db', () => ({ db: { select: mockDbSelect, insert: mockDbInsert, delete: mockDbDelete, update: mockDbUpdate, transaction: vi.fn(async (callback: (tx: Record) => unknown) => { return callback({ select: mockDbSelect, insert: mockDbInsert, delete: mockDbDelete, update: mockDbUpdate, }) }), }, })) vi.mock('drizzle-orm', () => ({ eq: vi.fn((field: string, value: string) => ({ field, value, type: 'eq' })), and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })), lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })), isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), })) vi.mock('@/lib/core/storage', () => ({ getStorageMethod: mockGetStorageMethod, })) const { mockCheckRateLimitDirect } = vi.hoisted(() => ({ mockCheckRateLimitDirect: vi.fn(), })) vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { checkRateLimitDirect = mockCheckRateLimitDirect }, })) vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail, })) vi.mock('@/components/emails', () => ({ renderOTPEmail: mockRenderOTPEmail, })) vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: (email: string, allowedEmails: string[]) => { if (allowedEmails.includes(email)) return true const atIndex = email.indexOf('@') if (atIndex > 0) { const domain = email.substring(atIndex + 1) if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) return true } return false }, })) vi.mock('@/app/api/chat/utils', () => ({ setChatAuthCookie: mockSetChatAuthCookie, })) vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/core/config/env', () => ({ env: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000', NODE_ENV: 'test', }, getEnv: mockGetEnv, isTruthy: vi.fn().mockReturnValue(false), isFalsy: vi.fn().mockReturnValue(true), })) vi.mock('zod', () => { class ZodError extends Error { errors: Array<{ message: string }> constructor(issues: Array<{ message: string }>) { super('ZodError') this.errors = issues } } const chainable: Record = {} const proxy: Record = new Proxy(chainable, { get(target, prop) { if (prop === 'parse') return mockZodParse if (prop === 'safeParse') { return (data: unknown) => ({ success: true, data }) } if (prop === 'then') return undefined if (typeof prop === 'symbol') return Reflect.get(target, prop) if (!(prop in target)) { target[prop as string] = vi.fn().mockReturnValue(proxy) } return target[prop as string] }, }) const makeChain = vi.fn(() => proxy) return { z: new Proxy( { ZodError }, { get(target, prop) { if (prop === 'ZodError') return ZodError if (typeof prop === 'symbol') return Reflect.get(target, prop) return makeChain }, } ), } }) import { POST, PUT } from './route' describe('Chat OTP API Route', () => { const mockEmail = 'test@example.com' const mockChatId = 'chat-123' const mockIdentifier = 'test-chat' const mockOTP = '123456' beforeEach(() => { vi.clearAllMocks() vi.spyOn(Math, 'random').mockReturnValue(0.123456) vi.spyOn(Date, 'now').mockReturnValue(1640995200000) vi.stubGlobal('crypto', { ...crypto, randomUUID: vi.fn().mockReturnValue('test-uuid-1234'), }) mockGetRedisClient.mockReturnValue(mockRedisClient) mockRedisSet.mockResolvedValue('OK') mockRedisGet.mockResolvedValue(null) mockRedisDel.mockResolvedValue(1) mockRedisTtl.mockResolvedValue(600) const createDbChain = (result: unknown) => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(result), }), }), }) mockDbSelect.mockImplementation(() => createDbChain([])) mockDbInsert.mockImplementation(() => ({ values: vi.fn().mockResolvedValue(undefined), })) mockDbDelete.mockImplementation(() => ({ where: vi.fn().mockResolvedValue(undefined), })) mockDbUpdate.mockImplementation(() => ({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }), })) mockGetStorageMethod.mockReturnValue('redis') mockSendEmail.mockResolvedValue({ success: true }) mockRenderOTPEmail.mockResolvedValue('OTP Email') mockCreateSuccessResponse.mockImplementation((data: unknown) => ({ json: () => Promise.resolve(data), status: 200, })) mockCreateErrorResponse.mockImplementation((message: string, status: number) => ({ json: () => Promise.resolve({ error: message }), status, })) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123') requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4') mockCheckRateLimitDirect.mockResolvedValue({ allowed: true, remaining: 10, resetAt: new Date(Date.now() + 60_000), }) mockZodParse.mockImplementation((data: unknown) => data) mockGetEnv.mockReturnValue('http://localhost:3000') }) afterEach(() => { vi.restoreAllMocks() }) describe('POST - Store OTP (Redis path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('redis') }) it('should store OTP in Redis when storage method is redis', async () => { mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { id: mockChatId, authType: 'email', allowedEmails: [mockEmail], title: 'Test Chat', }, ]), }), }), })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', body: JSON.stringify({ email: mockEmail }), }) await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisSet).toHaveBeenCalledWith( `otp:${mockEmail}:${mockChatId}`, expect.any(String), 'EX', 900 // 15 minutes ) expect(mockDbInsert).not.toHaveBeenCalled() }) }) describe('POST - Rate limiting', () => { const buildDeploymentSelect = () => mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { id: mockChatId, authType: 'email', allowedEmails: [mockEmail], title: 'Test Chat', }, ]), }), }), })) it('returns 429 with Retry-After when IP rate limit is exceeded', async () => { mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, remaining: 0, resetAt: new Date(Date.now() + 900_000), retryAfterMs: 900_000, }) const headerSet = vi.fn() mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ json: () => Promise.resolve({ error: message }), status, headers: { set: headerSet }, })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', body: JSON.stringify({ email: mockEmail }), }) const response = await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }), }) expect(response.status).toBe(429) expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') expect(mockSendEmail).not.toHaveBeenCalled() expect(mockDbSelect).not.toHaveBeenCalled() }) it('returns 429 with Retry-After when email rate limit is exceeded', async () => { mockCheckRateLimitDirect .mockResolvedValueOnce({ allowed: true, remaining: 9, resetAt: new Date(Date.now() + 60_000), }) .mockResolvedValueOnce({ allowed: false, remaining: 0, resetAt: new Date(Date.now() + 900_000), retryAfterMs: 900_000, }) const headerSet = vi.fn() mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ json: () => Promise.resolve({ error: message }), status, headers: { set: headerSet }, })) buildDeploymentSelect() const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', body: JSON.stringify({ email: mockEmail }), }) const response = await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }), }) expect(response.status).toBe(429) expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') expect(mockSendEmail).not.toHaveBeenCalled() }) it('falls back to refill interval when retryAfterMs is missing', async () => { mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, remaining: 0, resetAt: new Date(Date.now() + 900_000), }) const headerSet = vi.fn() mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({ json: () => Promise.resolve({ error: message }), status, headers: { set: headerSet }, })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', body: JSON.stringify({ email: mockEmail }), }) await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') }) it('folds spoofed `unknown` client IPs into a single shared bucket', async () => { requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown') buildDeploymentSelect() const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', body: JSON.stringify({ email: mockEmail }), }) await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2) expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( expect.stringMatching(/^chat-otp:ip:.*:unknown$/), expect.any(Object) ) expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( expect.stringContaining('chat-otp:email:'), expect.any(Object) ) }) }) describe('POST - Store OTP (Database path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('database') mockGetRedisClient.mockReturnValue(null) }) it('should store OTP in database when storage method is database', async () => { mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { id: mockChatId, authType: 'email', allowedEmails: [mockEmail], title: 'Test Chat', }, ]), }), }), })) const mockInsertValues = vi.fn().mockResolvedValue(undefined) mockDbInsert.mockImplementationOnce(() => ({ values: mockInsertValues, })) const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) mockDbDelete.mockImplementation(() => ({ where: mockDeleteWhere, })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', body: JSON.stringify({ email: mockEmail }), }) await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockDbDelete).toHaveBeenCalled() expect(mockDbInsert).toHaveBeenCalled() expect(mockInsertValues).toHaveBeenCalledWith({ id: expect.any(String), identifier: `chat-otp:${mockChatId}:${mockEmail}`, value: expect.any(String), expiresAt: expect.any(Date), createdAt: expect.any(Date), updatedAt: expect.any(Date), }) expect(mockRedisSet).not.toHaveBeenCalled() }) }) describe('PUT - Verify OTP (Redis path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('redis') mockRedisGet.mockResolvedValue(`${mockOTP}:0`) }) it('should retrieve OTP from Redis and verify successfully', async () => { mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { id: mockChatId, authType: 'email', }, ]), }), }), })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: mockOTP }), }) await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) expect(mockDbSelect).toHaveBeenCalledTimes(1) }) }) describe('PUT - Verify OTP (authType re-check)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('redis') mockRedisGet.mockResolvedValue(`${mockOTP}:0`) }) it('rejects verification when the chat has switched away from email auth', async () => { mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { id: mockChatId, authType: 'password', password: 'encrypted-password', }, ]), }), }), })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: mockOTP }), }) await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockCreateErrorResponse).toHaveBeenCalledWith( 'This chat does not use email authentication', 400 ) expect(mockRedisGet).not.toHaveBeenCalled() expect(mockSetChatAuthCookie).not.toHaveBeenCalled() }) }) describe('PUT - Verify OTP (Database path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('database') mockGetRedisClient.mockReturnValue(null) }) it('should retrieve OTP from database and verify successfully', async () => { let selectCallCount = 0 mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => { selectCallCount++ if (selectCallCount === 1) { return Promise.resolve([ { id: mockChatId, authType: 'email', }, ]) } return Promise.resolve([ { value: `${mockOTP}:0`, expiresAt: new Date(Date.now() + 10 * 60 * 1000), }, ]) }), }), }), })) const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) mockDbDelete.mockImplementation(() => ({ where: mockDeleteWhere, })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: mockOTP }), }) await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockDbSelect).toHaveBeenCalledTimes(2) expect(mockDbDelete).toHaveBeenCalled() expect(mockRedisGet).not.toHaveBeenCalled() }) it('should reject expired OTP from database', async () => { let selectCallCount = 0 mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => { selectCallCount++ if (selectCallCount === 1) { return Promise.resolve([ { id: mockChatId, authType: 'email', }, ]) } return Promise.resolve([]) }), }), }), })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: mockOTP }), }) await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockCreateErrorResponse).toHaveBeenCalledWith( 'No verification code found, request a new one', 400 ) }) }) describe('DELETE OTP (Redis path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('redis') }) it('should delete OTP from Redis after verification', async () => { mockRedisGet.mockResolvedValue(`${mockOTP}:0`) mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { id: mockChatId, authType: 'email', }, ]), }), }), })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: mockOTP }), }) await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) expect(mockDbDelete).not.toHaveBeenCalled() }) }) describe('DELETE OTP (Database path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('database') mockGetRedisClient.mockReturnValue(null) }) it('should delete OTP from database after verification', async () => { let selectCallCount = 0 mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockImplementation(() => { selectCallCount++ if (selectCallCount === 1) { return Promise.resolve([{ id: mockChatId, authType: 'email' }]) } return Promise.resolve([ { value: `${mockOTP}:0`, expiresAt: new Date(Date.now() + 10 * 60 * 1000) }, ]) }), }), }), })) const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) mockDbDelete.mockImplementation(() => ({ where: mockDeleteWhere, })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: mockOTP }), }) await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockDbDelete).toHaveBeenCalled() expect(mockRedisDel).not.toHaveBeenCalled() }) }) describe('Brute-force protection', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('redis') }) it('should atomically increment attempts on wrong OTP', async () => { mockRedisGet.mockResolvedValue('654321:0') mockRedisEval.mockResolvedValue('654321:1') mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), }), }), })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: 'wrong1' }), }) await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisEval).toHaveBeenCalledWith( expect.any(String), 1, `otp:${mockEmail}:${mockChatId}`, 5 ) expect(mockCreateErrorResponse).toHaveBeenCalledWith('Invalid verification code', 400) }) it('should invalidate OTP and return 429 after max failed attempts', async () => { mockRedisGet.mockResolvedValue('654321:4') mockRedisEval.mockResolvedValue('LOCKED') mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), }), }), })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: 'wrong5' }), }) await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisEval).toHaveBeenCalled() expect(mockCreateErrorResponse).toHaveBeenCalledWith( 'Too many failed attempts. Please request a new code.', 429 ) }) it('should store OTP with zero attempts on generation', async () => { mockDbSelect.mockImplementationOnce(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { id: mockChatId, authType: 'email', allowedEmails: [mockEmail], title: 'Test Chat', }, ]), }), }), })) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', body: JSON.stringify({ email: mockEmail }), }) await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisSet).toHaveBeenCalledWith( `otp:${mockEmail}:${mockChatId}`, expect.stringMatching(/^\d{6}:0$/), 'EX', 900 ) }) }) describe('Behavior consistency between Redis and Database', () => { it('should have same behavior for missing OTP in both storage methods', async () => { mockGetStorageMethod.mockReturnValue('redis') mockRedisGet.mockResolvedValue(null) mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), }), }), })) const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', body: JSON.stringify({ email: mockEmail, otp: mockOTP }), }) await PUT(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockCreateErrorResponse).toHaveBeenCalledWith( 'No verification code found, request a new one', 400 ) }) it('should have same OTP expiry time in both storage methods', async () => { const OTP_EXPIRY = 15 * 60 mockGetStorageMethod.mockReturnValue('redis') mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([ { id: mockChatId, authType: 'email', allowedEmails: [mockEmail], title: 'Test Chat', }, ]), }), }), })) const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', body: JSON.stringify({ email: mockEmail }), }) await POST(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisSet).toHaveBeenCalledWith( expect.any(String), expect.any(String), 'EX', OTP_EXPIRY ) }) }) })