d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
884 lines
26 KiB
TypeScript
884 lines
26 KiB
TypeScript
/**
|
|
* 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<string, unknown>) => 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<string, unknown> = {}
|
|
const proxy: Record<string, unknown> = 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('<html>OTP Email</html>')
|
|
|
|
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
|
|
)
|
|
})
|
|
})
|
|
})
|