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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Tests for chat identifier API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
encryptionMock,
|
||||
executionPreprocessingMock,
|
||||
executionPreprocessingMockFns,
|
||||
loggingSessionMock,
|
||||
workflowsApiUtilsMock,
|
||||
workflowsApiUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Creates a mock NextRequest with cookies support for testing.
|
||||
*/
|
||||
function createMockNextRequest(
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
url = 'http://localhost:3000/api/test'
|
||||
): any {
|
||||
const headersObj = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
})
|
||||
|
||||
const parsedUrl = new URL(url)
|
||||
|
||||
return {
|
||||
method,
|
||||
headers: headersObj,
|
||||
nextUrl: parsedUrl,
|
||||
cookies: {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
json:
|
||||
body !== undefined
|
||||
? vi.fn().mockResolvedValue(body)
|
||||
: vi.fn().mockRejectedValue(new Error('No body')),
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
const createMockStream = () => {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"blockId":"agent-1","chunk":"Hello"}\n\n')
|
||||
)
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"blockId":"agent-1","chunk":" world"}\n\n')
|
||||
)
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"event":"final","data":{"success":true}}\n\n')
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
mockValidateChatAuth,
|
||||
mockSetChatAuthCookie,
|
||||
mockValidateAuthToken,
|
||||
mockAssertChatEmbedAllowed,
|
||||
} = vi.hoisted(() => ({
|
||||
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
|
||||
mockSetChatAuthCookie: vi.fn(),
|
||||
mockValidateAuthToken: vi.fn().mockReturnValue(false),
|
||||
mockAssertChatEmbedAllowed: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
|
||||
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
...dbChainMock,
|
||||
chat: {},
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
validateAuthToken: mockValidateAuthToken,
|
||||
setDeploymentAuthCookie: vi.fn(),
|
||||
isEmailAllowed: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/chat/utils', () => ({
|
||||
validateChatAuth: mockValidateChatAuth,
|
||||
setChatAuthCookie: mockSetChatAuthCookie,
|
||||
assertChatEmbedAllowed: mockAssertChatEmbedAllowed,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
|
||||
|
||||
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
|
||||
|
||||
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
|
||||
|
||||
vi.mock('@/lib/workflows/streaming/streaming', () => ({
|
||||
createStreamingResponse: vi.fn().mockImplementation(async () => createMockStream()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
|
||||
executeWorkflow: vi.fn().mockResolvedValue({ success: true, output: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/sse', () => ({
|
||||
SSE_HEADERS: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
||||
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||
import { GET, POST } from '@/app/api/chat/[identifier]/route'
|
||||
|
||||
describe('Chat Identifier API Route', () => {
|
||||
const mockChatResult = [
|
||||
{
|
||||
id: 'chat-id',
|
||||
workflowId: 'workflow-id',
|
||||
userId: 'user-id',
|
||||
isActive: true,
|
||||
authType: 'public',
|
||||
title: 'Test Chat',
|
||||
description: 'Test chat description',
|
||||
customizations: {
|
||||
welcomeMessage: 'Welcome to the test chat',
|
||||
primaryColor: '#000000',
|
||||
},
|
||||
outputConfigs: [{ blockId: 'block-1', path: 'output' }],
|
||||
},
|
||||
]
|
||||
|
||||
const mockWorkflowResult = [
|
||||
{
|
||||
isDeployed: true,
|
||||
state: {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
},
|
||||
deployedState: {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValue({
|
||||
success: true,
|
||||
actorUserId: 'test-user-id',
|
||||
workflowRecord: {
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
isDeployed: true,
|
||||
workspaceId: 'test-workspace-id',
|
||||
variables: {},
|
||||
},
|
||||
userSubscription: {
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
},
|
||||
rateLimitInfo: {
|
||||
allowed: true,
|
||||
remaining: 100,
|
||||
resetAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
mockValidateChatAuth.mockResolvedValue({ authorized: true })
|
||||
mockValidateAuthToken.mockReturnValue(false)
|
||||
mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: code || 'Error',
|
||||
message,
|
||||
}),
|
||||
{ status }
|
||||
)
|
||||
})
|
||||
mockCreateSuccessResponse.mockImplementation((data: unknown) => {
|
||||
return new Response(JSON.stringify(data), { status: 200 })
|
||||
})
|
||||
|
||||
dbChainMockFns.select.mockImplementation((fields: Record<string, unknown>) => {
|
||||
if (fields && fields.isDeployed !== undefined) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue(mockWorkflowResult),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue(mockChatResult),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET endpoint', () => {
|
||||
it('should return chat info for a valid identifier', async () => {
|
||||
const req = createMockNextRequest('GET')
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('id', 'chat-id')
|
||||
expect(data).toHaveProperty('title', 'Test Chat')
|
||||
expect(data).toHaveProperty('description', 'Test chat description')
|
||||
expect(data).toHaveProperty('customizations')
|
||||
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
|
||||
})
|
||||
|
||||
it('should return 403 when embedding is blocked for a cross-origin caller', async () => {
|
||||
mockAssertChatEmbedAllowed.mockResolvedValueOnce(
|
||||
NextResponse.json(
|
||||
{ error: 'Embedding this chat on external sites requires a paid plan' },
|
||||
{
|
||||
status: 403,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const req = createMockNextRequest('GET', undefined, { origin: 'https://evil.example.com' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent identifier', async () => {
|
||||
dbChainMockFns.select.mockImplementation(() => {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('GET')
|
||||
const params = Promise.resolve({ identifier: 'nonexistent' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Chat not found')
|
||||
})
|
||||
|
||||
it('should return 403 for inactive chat', async () => {
|
||||
dbChainMockFns.select.mockImplementation(() => {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: 'chat-id',
|
||||
isActive: false,
|
||||
authType: 'public',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('GET')
|
||||
const params = Promise.resolve({ identifier: 'inactive-chat' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'This chat is currently unavailable')
|
||||
})
|
||||
|
||||
it('should return 401 when authentication is required', async () => {
|
||||
mockValidateChatAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'auth_required_password',
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('GET')
|
||||
const params = Promise.resolve({ identifier: 'password-protected-chat' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'auth_required_password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST endpoint', () => {
|
||||
it('should return 403 when embedding is blocked for a cross-origin caller', async () => {
|
||||
mockAssertChatEmbedAllowed.mockResolvedValueOnce(
|
||||
NextResponse.json(
|
||||
{ error: 'Embedding this chat on external sites requires a paid plan' },
|
||||
{
|
||||
status: 403,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const req = createMockNextRequest(
|
||||
'POST',
|
||||
{ input: 'Hello' },
|
||||
{ origin: 'https://evil.example.com' }
|
||||
)
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should return chat config on successful authentication', async () => {
|
||||
const req = createMockNextRequest('POST', { password: 'test-password' })
|
||||
const params = Promise.resolve({ identifier: 'password-protected-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('id', 'chat-id')
|
||||
expect(data).toHaveProperty('title', 'Test Chat')
|
||||
expect(data).toHaveProperty('customizations')
|
||||
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
|
||||
|
||||
expect(mockSetChatAuthCookie).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 400 for requests without input', async () => {
|
||||
const req = createMockNextRequest('POST', {})
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'No input provided')
|
||||
})
|
||||
|
||||
it('should return 401 for unauthorized access', async () => {
|
||||
mockValidateChatAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'Authentication required',
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('POST', { input: 'Hello' })
|
||||
const params = Promise.resolve({ identifier: 'protected-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Authentication required')
|
||||
})
|
||||
|
||||
it('should return 503 when workflow is not available', async () => {
|
||||
vi.mocked(preprocessExecution).mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Workflow is not deployed',
|
||||
statusCode: 403,
|
||||
logCreated: false,
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('POST', { input: 'Hello' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Workflow is not deployed')
|
||||
})
|
||||
|
||||
it('should return streaming response for valid chat messages', async () => {
|
||||
const req = createMockNextRequest('POST', {
|
||||
input: 'Hello world',
|
||||
conversationId: 'conv-123',
|
||||
})
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('text/event-stream')
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-cache')
|
||||
expect(response.headers.get('Connection')).toBe('keep-alive')
|
||||
|
||||
expect(createStreamingResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executeFn: expect.any(Function),
|
||||
streamConfig: expect.objectContaining({
|
||||
isSecureMode: true,
|
||||
workflowTriggerType: 'chat',
|
||||
}),
|
||||
})
|
||||
)
|
||||
}, 10000)
|
||||
|
||||
it('should handle streaming response body correctly', async () => {
|
||||
const req = createMockNextRequest('POST', { input: 'Hello world' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body).toBeInstanceOf(ReadableStream)
|
||||
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader()
|
||||
const { value, done } = await reader.read()
|
||||
|
||||
if (!done && value) {
|
||||
const chunk = new TextDecoder().decode(value)
|
||||
expect(chunk).toMatch(/^data: /)
|
||||
}
|
||||
|
||||
reader.releaseLock()
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle workflow execution errors gracefully', async () => {
|
||||
vi.mocked(createStreamingResponse).mockImplementationOnce(async () => {
|
||||
throw new Error('Execution failed')
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('POST', { input: 'Trigger error' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Execution failed')
|
||||
})
|
||||
|
||||
it('should handle invalid JSON in request body', async () => {
|
||||
const req = {
|
||||
method: 'POST',
|
||||
headers: new Headers(),
|
||||
nextUrl: new URL('http://localhost:3000/api/test'),
|
||||
cookies: { get: vi.fn().mockReturnValue(undefined) },
|
||||
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
} as any
|
||||
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Invalid request body')
|
||||
})
|
||||
|
||||
it('should pass conversationId to streaming execution when provided', async () => {
|
||||
const req = createMockNextRequest('POST', {
|
||||
input: 'Hello world',
|
||||
conversationId: 'test-conversation-123',
|
||||
})
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
await POST(req, { params })
|
||||
|
||||
expect(createStreamingResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executeFn: expect.any(Function),
|
||||
streamConfig: expect.objectContaining({
|
||||
workflowTriggerType: 'chat',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing conversationId gracefully', async () => {
|
||||
const req = createMockNextRequest('POST', { input: 'Hello world' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
await POST(req, { params })
|
||||
|
||||
expect(createStreamingResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executeFn: expect.any(Function),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { deployedChatPostContract } from '@/lib/api/contracts/chats'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
|
||||
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { validateAuthToken } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { ChatFiles } from '@/lib/uploads'
|
||||
import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatIdentifierAPI')
|
||||
|
||||
interface ChatConfigSource {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
customizations: unknown
|
||||
authType: string | null
|
||||
outputConfigs: unknown
|
||||
}
|
||||
|
||||
function toChatConfigResponse(deployment: ChatConfigSource) {
|
||||
return {
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
outputConfigs: deployment.outputConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const { identifier } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const ticket = tryAdmit()
|
||||
if (!ticket) {
|
||||
return admissionRejectedResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(deployedChatPostContract, request, context, {
|
||||
maxBodyBytes: CHAT_MAX_REQUEST_BYTES,
|
||||
validationErrorResponse: (err) => {
|
||||
const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')
|
||||
return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR')
|
||||
},
|
||||
invalidJsonResponse: () => createErrorResponse('Invalid request body', 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const parsedBody = parsed.data.body
|
||||
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
workflowId: chat.workflowId,
|
||||
userId: chat.userId,
|
||||
isActive: chat.isActive,
|
||||
authType: chat.authType,
|
||||
password: chat.password,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), 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.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`
|
||||
)
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const executionId = generateId()
|
||||
const loggingSession = new LoggingSession(
|
||||
deployment.workflowId,
|
||||
executionId,
|
||||
'chat',
|
||||
requestId
|
||||
)
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: deployment.userId,
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
error: {
|
||||
message: 'This chat is currently unavailable. The chat has been disabled.',
|
||||
stackTrace: undefined,
|
||||
},
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId)
|
||||
if (embedBlock) return embedBlock
|
||||
|
||||
const authResult = await validateChatAuth(requestId, deployment, request, parsedBody)
|
||||
if (!authResult.authorized) {
|
||||
const response = createErrorResponse(
|
||||
authResult.error || 'Authentication required',
|
||||
authResult.status || 401
|
||||
)
|
||||
if (authResult.status === 429 && authResult.retryAfterMs !== undefined) {
|
||||
response.headers.set('Retry-After', String(Math.ceil(authResult.retryAfterMs / 1000)))
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const { input, password, email, conversationId, files } = parsedBody
|
||||
|
||||
if ((password || email) && !input) {
|
||||
const response = createSuccessResponse(toChatConfigResponse(deployment))
|
||||
|
||||
if (deployment.authType !== 'sso') {
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
if (!input && (!files || files.length === 0)) {
|
||||
return createErrorResponse('No input provided', 400)
|
||||
}
|
||||
|
||||
const executionId = generateId()
|
||||
|
||||
const loggingSession = new LoggingSession(
|
||||
deployment.workflowId,
|
||||
executionId,
|
||||
'chat',
|
||||
requestId
|
||||
)
|
||||
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId: deployment.workflowId,
|
||||
userId: deployment.userId,
|
||||
triggerType: 'chat',
|
||||
executionId,
|
||||
requestId,
|
||||
checkRateLimit: true,
|
||||
checkDeployment: true,
|
||||
loggingSession,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
|
||||
return createErrorResponse(
|
||||
preprocessResult.error?.message || 'Failed to process request',
|
||||
preprocessResult.error?.statusCode || 500
|
||||
)
|
||||
}
|
||||
|
||||
const { actorUserId, workflowRecord } = preprocessResult
|
||||
const workspaceOwnerId = actorUserId!
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
|
||||
// preprocessExecution reserved a billing concurrency slot; release it on
|
||||
// this early exit since no LoggingSession will finalize to free it.
|
||||
await releaseExecutionSlot(executionId)
|
||||
return createErrorResponse('Workflow has no associated workspace', 500)
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedOutputs: string[] = []
|
||||
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
|
||||
for (const config of deployment.outputConfigs) {
|
||||
const outputId = config.path
|
||||
? `${config.blockId}_${config.path}`
|
||||
: `${config.blockId}_content`
|
||||
selectedOutputs.push(outputId)
|
||||
}
|
||||
}
|
||||
|
||||
const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming')
|
||||
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
|
||||
const { SSE_HEADERS } = await import('@/lib/core/utils/sse')
|
||||
|
||||
const workflowInput: any = { input, conversationId }
|
||||
if (files && Array.isArray(files) && files.length > 0) {
|
||||
const executionContext = {
|
||||
workspaceId,
|
||||
workflowId: deployment.workflowId,
|
||||
executionId,
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadedFiles = await ChatFiles.processChatFiles(
|
||||
files,
|
||||
executionContext,
|
||||
requestId,
|
||||
deployment.userId
|
||||
)
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
workflowInput.files = uploadedFiles
|
||||
logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`)
|
||||
}
|
||||
} catch (fileError: any) {
|
||||
logger.error(`[${requestId}] Failed to process chat files:`, fileError)
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: workspaceOwnerId,
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
error: {
|
||||
message: `File upload failed: ${fileError.message || 'Unable to process uploaded files'}`,
|
||||
stackTrace: fileError.stack,
|
||||
},
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
throw fileError
|
||||
}
|
||||
}
|
||||
|
||||
const workflowForExecution = {
|
||||
id: deployment.workflowId,
|
||||
userId: deployment.userId,
|
||||
workspaceId,
|
||||
isDeployed: workflowRecord?.isDeployed ?? false,
|
||||
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,
|
||||
}
|
||||
|
||||
const stream = await createStreamingResponse({
|
||||
requestId,
|
||||
streamConfig: {
|
||||
selectedOutputs,
|
||||
isSecureMode: true,
|
||||
workflowTriggerType: 'chat',
|
||||
},
|
||||
executionId,
|
||||
workspaceId,
|
||||
workflowId: deployment.workflowId,
|
||||
userId: workspaceOwnerId,
|
||||
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
|
||||
executeWorkflow(
|
||||
workflowForExecution,
|
||||
requestId,
|
||||
workflowInput,
|
||||
workspaceOwnerId,
|
||||
{
|
||||
enabled: true,
|
||||
selectedOutputs,
|
||||
isSecureMode: true,
|
||||
workflowTriggerType: 'chat',
|
||||
onStream,
|
||||
onBlockComplete,
|
||||
skipLoggingComplete: true,
|
||||
abortSignal,
|
||||
executionMode: 'stream',
|
||||
},
|
||||
executionId
|
||||
),
|
||||
})
|
||||
|
||||
const streamResponse = new NextResponse(stream, {
|
||||
status: 200,
|
||||
headers: SSE_HEADERS,
|
||||
})
|
||||
return streamResponse
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error processing chat request:`, error)
|
||||
// Setup failed before the workflow stream took over slot release;
|
||||
// free the reserved billing slot (idempotent if already released).
|
||||
await releaseExecutionSlot(executionId)
|
||||
return createErrorResponse(error.message || 'Failed to process request', 500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error processing chat request:`, error)
|
||||
return createErrorResponse(error.message || 'Failed to process request', 500)
|
||||
} finally {
|
||||
ticket.release()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => {
|
||||
const { identifier } = await params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
isActive: chat.isActive,
|
||||
workflowId: chat.workflowId,
|
||||
authType: chat.authType,
|
||||
password: chat.password,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), 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.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId)
|
||||
if (embedBlock) return embedBlock
|
||||
|
||||
const cookieName = `chat_auth_${deployment.id}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
|
||||
if (
|
||||
deployment.authType !== 'public' &&
|
||||
deployment.authType !== 'sso' &&
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password)
|
||||
) {
|
||||
return createSuccessResponse(toChatConfigResponse(deployment))
|
||||
}
|
||||
|
||||
const authResult = await validateChatAuth(requestId, deployment, request)
|
||||
if (!authResult.authorized) {
|
||||
logger.info(
|
||||
`[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}`
|
||||
)
|
||||
return createErrorResponse(authResult.error || 'Authentication required', 401)
|
||||
}
|
||||
|
||||
return createSuccessResponse(toChatConfigResponse(deployment))
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching chat info:`, error)
|
||||
return createErrorResponse(error.message || 'Failed to fetch chat information', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { chatSSOContract } from '@/lib/api/contracts/chats'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatSSOAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 20,
|
||||
refillRate: 20,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`chat-sso:ip:${ip}`,
|
||||
SSO_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
|
||||
const retryAfter = Math.ceil(
|
||||
(ipRateLimit.retryAfterMs ?? SSO_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(chatSSOContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { identifier } = parsed.data.params
|
||||
const { email } = parsed.data.body
|
||||
|
||||
const [deployment] = await db
|
||||
.select({
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
isActive: chat.isActive,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!deployment || !deployment.isActive) {
|
||||
logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
if (deployment.authType !== 'sso') {
|
||||
return createErrorResponse('Chat is not configured for SSO authentication', 400)
|
||||
}
|
||||
|
||||
const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])
|
||||
|
||||
return createSuccessResponse({ eligible })
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user