chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,883 @@
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { renderOTPEmail } from '@/components/emails'
|
||||
import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import {
|
||||
decodeOTPValue,
|
||||
deleteOTP,
|
||||
generateOTP,
|
||||
getOTP,
|
||||
incrementOTPAttempts,
|
||||
MAX_OTP_ATTEMPTS,
|
||||
OTP_EMAIL_RATE_LIMIT,
|
||||
OTP_IP_RATE_LIMIT,
|
||||
storeOTP,
|
||||
} from '@/lib/core/security/otp'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { setChatAuthCookie } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatOtpAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const { identifier } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`chat-otp:ip:${identifier}:${ip}`,
|
||||
OTP_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
|
||||
const retryAfter = Math.ceil(
|
||||
(ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
|
||||
)
|
||||
const response = createErrorResponse('Too many requests. Please try again later.', 429)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return response
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(requestChatEmailOtpContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email } = parsed.data.body
|
||||
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
title: chat.title,
|
||||
})
|
||||
.from(chat)
|
||||
.where(
|
||||
and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (deployment.authType !== 'email') {
|
||||
return createErrorResponse('This chat does not use email authentication', 400)
|
||||
}
|
||||
|
||||
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
|
||||
? deployment.allowedEmails
|
||||
: []
|
||||
|
||||
if (!isEmailAllowed(email, allowedEmails)) {
|
||||
return createErrorResponse('Email not authorized for this chat', 403)
|
||||
}
|
||||
|
||||
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`chat-otp:email:${deployment.id}:${email.toLowerCase()}`,
|
||||
OTP_EMAIL_RATE_LIMIT
|
||||
)
|
||||
if (!emailRateLimit.allowed) {
|
||||
logger.warn(
|
||||
`[${requestId}] OTP email rate limit exceeded for ${email} on chat ${deployment.id}`
|
||||
)
|
||||
const retryAfter = Math.ceil(
|
||||
(emailRateLimit.retryAfterMs ?? OTP_EMAIL_RATE_LIMIT.refillIntervalMs) / 1000
|
||||
)
|
||||
const response = createErrorResponse(
|
||||
'Too many verification code requests. Please try again later.',
|
||||
429
|
||||
)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return response
|
||||
}
|
||||
|
||||
const otp = generateOTP()
|
||||
await storeOTP('chat', deployment.id, email, otp)
|
||||
|
||||
const emailHtml = await renderOTPEmail(
|
||||
otp,
|
||||
email,
|
||||
'email-verification',
|
||||
deployment.title || 'Chat'
|
||||
)
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: `Verification code for ${deployment.title || 'Chat'}`,
|
||||
html: emailHtml,
|
||||
})
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
|
||||
return createErrorResponse('Failed to send verification email', 500)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`)
|
||||
return createSuccessResponse({ message: 'Verification code sent' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing OTP request:`, error)
|
||||
return createErrorResponse('Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const { identifier } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email, otp } = parsed.data.body
|
||||
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
authType: chat.authType,
|
||||
password: chat.password,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
})
|
||||
.from(chat)
|
||||
.where(
|
||||
and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (deployment.authType !== 'email') {
|
||||
return createErrorResponse('This chat does not use email authentication', 400)
|
||||
}
|
||||
|
||||
const storedValue = await getOTP('chat', deployment.id, email)
|
||||
if (!storedValue) {
|
||||
return createErrorResponse('No verification code found, request a new one', 400)
|
||||
}
|
||||
|
||||
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
|
||||
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await deleteOTP('chat', deployment.id, email)
|
||||
logger.warn(`[${requestId}] OTP already locked out for ${email}`)
|
||||
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
|
||||
}
|
||||
|
||||
if (storedOTP !== otp) {
|
||||
const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue)
|
||||
if (result === 'locked') {
|
||||
logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
|
||||
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
|
||||
}
|
||||
return createErrorResponse('Invalid verification code', 400)
|
||||
}
|
||||
|
||||
await deleteOTP('chat', deployment.id, email)
|
||||
|
||||
const response = createSuccessResponse({
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
outputConfigs: deployment.outputConfigs,
|
||||
})
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error verifying OTP:`, error)
|
||||
return createErrorResponse('Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user