feat(chat-otp): added db fallback for chat otp (#2582)

* feat(chat-otp): added db fallback for chat otp

* ack PR comments
This commit is contained in:
Waleed
2025-12-25 09:37:20 -08:00
committed by GitHub
parent 26ec12599f
commit f604ca39a5
4 changed files with 588 additions and 34 deletions

View File

@@ -20,7 +20,7 @@ interface NavProps {
}
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('24k')
const [githubStars, setGithubStars] = useState('24.4k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()

View File

@@ -0,0 +1,550 @@
/**
* Tests for chat OTP API route
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('Chat OTP API Route', () => {
const mockEmail = 'test@example.com'
const mockChatId = 'chat-123'
const mockIdentifier = 'test-chat'
const mockOTP = '123456'
const mockRedisSet = vi.fn()
const mockRedisGet = vi.fn()
const mockRedisDel = vi.fn()
const mockGetRedisClient = vi.fn()
const mockDbSelect = vi.fn()
const mockDbInsert = vi.fn()
const mockDbDelete = vi.fn()
const mockSendEmail = vi.fn()
const mockRenderOTPEmail = vi.fn()
const mockAddCorsHeaders = vi.fn()
const mockCreateSuccessResponse = vi.fn()
const mockCreateErrorResponse = vi.fn()
const mockSetChatAuthCookie = vi.fn()
const mockGenerateRequestId = vi.fn()
let storageMethod: 'redis' | 'database' = 'redis'
beforeEach(() => {
vi.resetModules()
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'),
})
const mockRedisClient = {
set: mockRedisSet,
get: mockRedisGet,
del: mockRedisDel,
}
mockGetRedisClient.mockReturnValue(mockRedisClient)
mockRedisSet.mockResolvedValue('OK')
mockRedisGet.mockResolvedValue(null)
mockRedisDel.mockResolvedValue(1)
vi.doMock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
const createDbChain = (result: any) => ({
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),
}))
vi.doMock('@sim/db', () => ({
db: {
select: mockDbSelect,
insert: mockDbInsert,
delete: mockDbDelete,
transaction: vi.fn(async (callback) => {
return callback({
select: mockDbSelect,
insert: mockDbInsert,
delete: mockDbDelete,
})
}),
},
}))
vi.doMock('@sim/db/schema', () => ({
chat: {
id: 'id',
authType: 'authType',
allowedEmails: 'allowedEmails',
title: 'title',
},
verification: {
id: 'id',
identifier: 'identifier',
value: 'value',
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
}))
vi.doMock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
and: vi.fn((...conditions) => ({ conditions, type: 'and' })),
gt: vi.fn((field, value) => ({ field, value, type: 'gt' })),
lt: vi.fn((field, value) => ({ field, value, type: 'lt' })),
}))
vi.doMock('@/lib/core/storage', () => ({
getStorageMethod: vi.fn(() => storageMethod),
}))
mockSendEmail.mockResolvedValue({ success: true })
mockRenderOTPEmail.mockResolvedValue('<html>OTP Email</html>')
vi.doMock('@/lib/messaging/email/mailer', () => ({
sendEmail: mockSendEmail,
}))
vi.doMock('@/components/emails/render-email', () => ({
renderOTPEmail: mockRenderOTPEmail,
}))
mockAddCorsHeaders.mockImplementation((response) => response)
mockCreateSuccessResponse.mockImplementation((data) => ({
json: () => Promise.resolve(data),
status: 200,
}))
mockCreateErrorResponse.mockImplementation((message, status) => ({
json: () => Promise.resolve({ error: message }),
status,
}))
vi.doMock('@/app/api/chat/utils', () => ({
addCorsHeaders: mockAddCorsHeaders,
setChatAuthCookie: mockSetChatAuthCookie,
}))
vi.doMock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse,
createErrorResponse: mockCreateErrorResponse,
}))
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
vi.doMock('zod', () => ({
z: {
object: vi.fn().mockReturnValue({
parse: vi.fn().mockImplementation((data) => data),
}),
string: vi.fn().mockReturnValue({
email: vi.fn().mockReturnThis(),
length: vi.fn().mockReturnThis(),
}),
},
}))
mockGenerateRequestId.mockReturnValue('req-123')
vi.doMock('@/lib/core/utils/request', () => ({
generateRequestId: mockGenerateRequestId,
}))
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST - Store OTP (Redis path)', () => {
beforeEach(() => {
storageMethod = 'redis'
})
it('should store OTP in Redis when storage method is redis', async () => {
const { POST } = await import('./route')
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 - Store OTP (Database path)', () => {
beforeEach(() => {
storageMethod = 'database'
mockGetRedisClient.mockReturnValue(null)
})
it('should store OTP in database when storage method is database', async () => {
const { POST } = await import('./route')
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(() => {
storageMethod = 'redis'
mockRedisGet.mockResolvedValue(mockOTP)
})
it('should retrieve OTP from Redis and verify successfully', async () => {
const { PUT } = await import('./route')
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 (Database path)', () => {
beforeEach(() => {
storageMethod = 'database'
mockGetRedisClient.mockReturnValue(null)
})
it('should retrieve OTP from database and verify successfully', async () => {
const { PUT } = await import('./route')
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,
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 () => {
const { PUT } = await import('./route')
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(() => {
storageMethod = 'redis'
})
it('should delete OTP from Redis after verification', async () => {
const { PUT } = await import('./route')
mockRedisGet.mockResolvedValue(mockOTP)
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(() => {
storageMethod = 'database'
mockGetRedisClient.mockReturnValue(null)
})
it('should delete OTP from database after verification', async () => {
const { PUT } = await import('./route')
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, 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('Behavior consistency between Redis and Database', () => {
it('should have same behavior for missing OTP in both storage methods', async () => {
storageMethod = 'redis'
mockRedisGet.mockResolvedValue(null)
const { PUT: PUTRedis } = await import('./route')
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 PUTRedis(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
storageMethod = 'redis'
const { POST: POSTRedis } = await import('./route')
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 POSTRedis(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) })
expect(mockRedisSet).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
'EX',
OTP_EXPIRY
)
})
})
})

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { chat, verification } from '@sim/db/schema'
import { and, eq, gt } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { renderOTPEmail } from '@/components/emails/render-email'
@@ -22,24 +23,11 @@ const OTP_EXPIRY = 15 * 60 // 15 minutes
const OTP_EXPIRY_MS = OTP_EXPIRY * 1000
/**
* In-memory OTP storage for single-instance deployments without Redis.
* Only used when REDIS_URL is not configured (determined once at startup).
*
* Warning: This does NOT work in multi-instance/serverless deployments.
* Stores OTP in Redis or database depending on storage method.
* Uses the verification table for database storage.
*/
const inMemoryOTPStore = new Map<string, { otp: string; expiresAt: number }>()
function cleanupExpiredOTPs() {
const now = Date.now()
for (const [key, value] of inMemoryOTPStore.entries()) {
if (value.expiresAt < now) {
inMemoryOTPStore.delete(key)
}
}
}
async function storeOTP(email: string, chatId: string, otp: string): Promise<void> {
const key = `otp:${email}:${chatId}`
const identifier = `chat-otp:${chatId}:${email}`
const storageMethod = getStorageMethod()
if (storageMethod === 'redis') {
@@ -47,18 +35,28 @@ async function storeOTP(email: string, chatId: string, otp: string): Promise<voi
if (!redis) {
throw new Error('Redis configured but client unavailable')
}
const key = `otp:${email}:${chatId}`
await redis.set(key, otp, 'EX', OTP_EXPIRY)
} else {
cleanupExpiredOTPs()
inMemoryOTPStore.set(key, {
otp,
expiresAt: Date.now() + OTP_EXPIRY_MS,
const now = new Date()
const expiresAt = new Date(now.getTime() + OTP_EXPIRY_MS)
await db.transaction(async (tx) => {
await tx.delete(verification).where(eq(verification.identifier, identifier))
await tx.insert(verification).values({
id: randomUUID(),
identifier,
value: otp,
expiresAt,
createdAt: now,
updatedAt: now,
})
})
}
}
async function getOTP(email: string, chatId: string): Promise<string | null> {
const key = `otp:${email}:${chatId}`
const identifier = `chat-otp:${chatId}:${email}`
const storageMethod = getStorageMethod()
if (storageMethod === 'redis') {
@@ -66,22 +64,27 @@ async function getOTP(email: string, chatId: string): Promise<string | null> {
if (!redis) {
throw new Error('Redis configured but client unavailable')
}
const key = `otp:${email}:${chatId}`
return redis.get(key)
}
const entry = inMemoryOTPStore.get(key)
if (!entry) return null
const now = new Date()
const [record] = await db
.select({
value: verification.value,
expiresAt: verification.expiresAt,
})
.from(verification)
.where(and(eq(verification.identifier, identifier), gt(verification.expiresAt, now)))
.limit(1)
if (entry.expiresAt < Date.now()) {
inMemoryOTPStore.delete(key)
return null
}
if (!record) return null
return entry.otp
return record.value
}
async function deleteOTP(email: string, chatId: string): Promise<void> {
const key = `otp:${email}:${chatId}`
const identifier = `chat-otp:${chatId}:${email}`
const storageMethod = getStorageMethod()
if (storageMethod === 'redis') {
@@ -89,9 +92,10 @@ async function deleteOTP(email: string, chatId: string): Promise<void> {
if (!redis) {
throw new Error('Redis configured but client unavailable')
}
const key = `otp:${email}:${chatId}`
await redis.del(key)
} else {
inMemoryOTPStore.delete(key)
await db.delete(verification).where(eq(verification.identifier, identifier))
}
}

View File

@@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const [starCount, setStarCount] = useState('24k')
const [starCount, setStarCount] = useState('24.4k')
const [conversationId, setConversationId] = useState('')
const [showScrollButton, setShowScrollButton] = useState(false)