chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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),
})
)
})
})
})
+388
View File
@@ -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 })
}
)
@@ -0,0 +1,412 @@
/**
* Tests for chat edit API route
*
* @vitest-environment node
*/
import {
auditMock,
authMockFns,
dbChainMock,
dbChainMockFns,
encryptionMock,
encryptionMockFns,
resetDbChainMock,
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
workflowsOrchestrationMock,
workflowsOrchestrationMockFns,
workflowsPersistenceUtilsMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckChatAccess } = vi.hoisted(() => ({
mockCheckChatAccess: vi.fn(),
}))
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockEncryptSecret = encryptionMockFns.mockEncryptSecret
const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy
const mockPerformChatUndeploy = workflowsOrchestrationMockFns.mockPerformChatUndeploy
const mockNotifySocketDeploymentChanged =
workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/core/config/env-flags', () => ({
isDev: true,
isHosted: false,
isProd: false,
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('localhost:3000'),
}))
vi.mock('@/app/api/chat/utils', () => ({
checkChatAccess: mockCheckChatAccess,
}))
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route'
describe('Chat Edit API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockPerformChatUndeploy.mockResolvedValue({ success: true })
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
})
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' })
mockPerformFullDeploy.mockResolvedValue({ success: true, version: 1 })
mockNotifySocketDeploymentChanged.mockResolvedValue(undefined)
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('should return 404 when chat not found or access denied', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Chat not found or access denied')
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should return chat details when user has access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
description: 'A test chat',
password: 'encrypted-password',
customizations: { primaryColor: '#000000' },
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
const data = await response.json()
expect(data.id).toBe('chat-123')
expect(data.identifier).toBe('test-chat')
expect(data.title).toBe('Test Chat')
expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat')
expect(data.hasPassword).toBe(true)
})
})
describe('PATCH', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('should return 404 when chat not found or access denied', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Chat not found or access denied')
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should update chat when user has access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: mockChat,
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(dbChainMockFns.update).toHaveBeenCalled()
const data = await response.json()
expect(data.id).toBe('chat-123')
expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat')
expect(data.message).toBe('Chat deployment updated successfully')
})
it('should handle identifier conflicts', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'other-chat-id', identifier: 'new-identifier' },
])
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ identifier: 'new-identifier' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Identifier already in use')
})
it('should validate password requirement for password auth', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
password: null,
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ authType: 'password' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Password is required when using password protection')
})
it('should keep the existing password when updating a password-protected chat', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'password',
password: 'encrypted-password',
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: mockChat,
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ authType: 'password', title: 'Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockEncryptSecret).not.toHaveBeenCalled()
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
authType: 'password',
allowedEmails: [],
updatedAt: expect.any(Date),
})
)
const updatePayload = dbChainMockFns.set.mock.calls[0]?.[0]
expect(updatePayload.password).toBeUndefined()
})
it('should allow access when user has workspace admin permission', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'admin-user-id' },
})
const mockChat = {
id: 'chat-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: mockChat,
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Admin Updated Chat' }),
})
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
})
})
describe('DELETE', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})
it('should return 404 when chat not found or access denied', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Chat not found or access denied')
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should delete chat when user has access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'user-id',
workspaceId: 'workspace-123',
})
const data = await response.json()
expect(data.message).toBe('Chat deployment deleted successfully')
})
it('should allow deletion when user has workspace admin permission', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'admin-user-id' },
})
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
chatId: 'chat-123',
userId: 'admin-user-id',
workspaceId: 'workspace-123',
})
})
})
})
+292
View File
@@ -0,0 +1,292 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { chatIdParamsSchema, updateChatContract } from '@/lib/api/contracts/chats'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/env-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performChatUndeploy, performFullDeploy } from '@/lib/workflows/orchestration'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
export const dynamic = 'force-dynamic'
export const maxDuration = 120
const logger = createLogger('ChatDetailAPI')
/**
* GET endpoint to fetch a specific chat deployment by ID
*/
export const GET = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const { id } = chatIdParamsSchema.parse(await params)
const chatId = id
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id)
if (!hasAccess || !chatRecord) {
return createErrorResponse('Chat not found or access denied', 404)
}
const { password, ...safeData } = chatRecord
const baseDomain = getEmailDomain()
const protocol = isDev ? 'http' : 'https'
const chatUrl = `${protocol}://${baseDomain}/chat/${chatRecord.identifier}`
const result = {
...safeData,
chatUrl,
hasPassword: !!password,
}
return createSuccessResponse(result)
} catch (error) {
logger.error('Error fetching chat deployment:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployment'), 500)
}
}
)
/**
* PATCH endpoint to update an existing chat deployment
*/
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
const parsed = await parseRequest(updateChatContract, request, context, {
validationErrorResponse: (error) =>
createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'),
})
if (!parsed.success) return parsed.response
const { id: chatId } = parsed.data.params
const validatedData = parsed.data.body
const {
hasAccess,
chat: existingChatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)
if (!hasAccess || !existingChatRecord) {
return createErrorResponse('Chat not found or access denied', 404)
}
const existingChat = [existingChatRecord]
const {
workflowId,
identifier,
title,
description,
customizations,
authType,
password,
allowedEmails,
outputConfigs,
} = validatedData
if (workflowId && workflowId !== existingChat[0].workflowId) {
return createErrorResponse('Changing the workflow of a chat deployment is not allowed', 400)
}
if (identifier && identifier !== existingChat[0].identifier) {
const existingIdentifier = await db
.select()
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1)
if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) {
return createErrorResponse('Identifier already in use', 400)
}
}
let encryptedPassword
if (password) {
const { encrypted } = await encryptSecret(password)
encryptedPassword = encrypted
logger.info('Password provided, will be updated')
} else if (authType === 'password' && !password) {
if (existingChat[0].authType !== 'password' || !existingChat[0].password) {
return createErrorResponse('Password is required when using password protection', 400)
}
logger.info('Keeping existing password')
}
// Redeploy the workflow to ensure latest version is active
const deployResult = await performFullDeploy({
workflowId: existingChat[0].workflowId,
userId: session.user.id,
request,
})
if (!deployResult.success) {
logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`)
const status =
deployResult.errorCode === 'validation'
? 400
: deployResult.errorCode === 'not_found'
? 404
: 500
return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status)
}
logger.info(
`Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})`
)
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
}
if (identifier) updateData.identifier = identifier
if (title) updateData.title = title
if (description !== undefined) updateData.description = description
if (customizations) updateData.customizations = customizations
if (authType) {
updateData.authType = authType
if (authType === 'public') {
updateData.password = null
updateData.allowedEmails = []
} else if (authType === 'password') {
updateData.allowedEmails = []
} else if (authType === 'email' || authType === 'sso') {
updateData.password = null
}
}
if (encryptedPassword) {
updateData.password = encryptedPassword
}
if (allowedEmails) {
updateData.allowedEmails = allowedEmails
}
if (outputConfigs) {
updateData.outputConfigs = outputConfigs
}
const emailCount = Array.isArray(updateData.allowedEmails)
? updateData.allowedEmails.length
: undefined
const outputConfigsCount = Array.isArray(updateData.outputConfigs)
? updateData.outputConfigs.length
: undefined
logger.info('Updating chat deployment with values:', {
chatId,
authType: updateData.authType,
hasPassword: updateData.password !== undefined,
emailCount,
outputConfigsCount,
})
await db.update(chat).set(updateData).where(eq(chat.id, chatId))
const updatedIdentifier = identifier || existingChat[0].identifier
const baseDomain = getEmailDomain()
const protocol = isDev ? 'http' : 'https'
const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}`
logger.info(`Chat "${chatId}" updated successfully`)
recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_UPDATED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: title || existingChatRecord.title,
description: `Updated chat deployment "${title || existingChatRecord.title}"`,
metadata: {
identifier: updatedIdentifier,
authType: updateData.authType || existingChatRecord.authType,
workflowId: workflowId || existingChatRecord.workflowId,
chatUrl,
},
request,
})
return createSuccessResponse({
id: chatId,
chatUrl,
message: 'Chat deployment updated successfully',
})
} catch (error) {
logger.error('Error updating chat deployment:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to update chat deployment'), 500)
}
}
)
/**
* DELETE endpoint to remove a chat deployment
*/
export const DELETE = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const { id } = chatIdParamsSchema.parse(await params)
const chatId = id
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
chatId,
session.user.id
)
if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404)
}
const result = await performChatUndeploy({
chatId,
userId: session.user.id,
workspaceId: chatWorkspaceId,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to delete chat', 500)
}
return createSuccessResponse({
message: 'Chat deployment deleted successfully',
})
} catch (error) {
logger.error('Error deleting chat deployment:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to delete chat deployment'), 500)
}
}
)
+419
View File
@@ -0,0 +1,419 @@
/**
* Tests for chat API route
*
* @vitest-environment node
*/
import {
authMockFns,
createEnvMock,
dbChainMock,
dbChainMockFns,
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
workflowsOrchestrationMock,
workflowsOrchestrationMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckWorkflowAccessForChatCreation } = vi.hoisted(() => ({
mockCheckWorkflowAccessForChatCreation: vi.fn(),
}))
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockPerformChatDeploy = workflowsOrchestrationMockFns.mockPerformChatDeploy
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
vi.mock('@/lib/core/config/env', () =>
createEnvMock({
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
})
)
import { GET, POST } from '@/app/api/chat/route'
describe('Chat API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
})
mockPerformChatDeploy.mockResolvedValue({
success: true,
chatId: 'test-uuid',
chatUrl: 'http://localhost:3000/chat/test-chat',
})
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat')
const response = await GET(req)
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should return chat deployments for authenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const mockDeployments = [{ id: 'deployment-1' }, { id: 'deployment-2' }]
dbChainMockFns.where.mockResolvedValueOnce(mockDeployments)
const req = new NextRequest('http://localhost:3000/api/chat')
const response = await GET(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ deployments: mockDeployments })
expect(dbChainMockFns.where).toHaveBeenCalled()
})
it('should handle errors when fetching deployments', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error'))
const req = new NextRequest('http://localhost:3000/api/chat')
const response = await GET(req)
expect(response.status).toBe(500)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Database error', 500)
})
})
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify({}),
})
const response = await POST(req)
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should validate request data', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const invalidData = { title: 'Test Chat' } // Missing required fields
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(invalidData),
})
const response = await POST(req)
expect(response.status).toBe(400)
})
it('should reject if identifier already exists', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'existing-chat' }]) // Identifier exists
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Identifier already in use', 400)
})
it('should reject if workflow not found', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(404)
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Workflow not found or access denied',
404
)
})
it('should allow chat deployment when user owns workflow directly', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
identifier: 'test-chat',
})
)
})
it('passes chat customizations and outputConfigs through in the API request shape', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
imageUrl: 'https://example.com/icon.png',
},
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
}
dbChainMockFns.limit.mockResolvedValueOnce([])
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
identifier: 'test-chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
imageUrl: 'https://example.com/icon.png',
},
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
})
)
})
it('should allow chat deployment when user has workspace admin permission', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
workspaceId: 'workspace-123',
})
)
})
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: false,
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(404)
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Workflow not found or access denied',
404
)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should handle workspace permission check errors gracefully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockRejectedValue(new Error('Permission check failed'))
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(500)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should call performChatDeploy for undeployed workflow', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', email: 'user@example.com' },
})
const validData = {
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
dbChainMockFns.limit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockPerformChatDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-123',
userId: 'user-id',
})
)
})
})
})
+132
View File
@@ -0,0 +1,132 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createChatContract } from '@/lib/api/contracts/chats'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performChatDeploy } from '@/lib/workflows/orchestration'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatAPI')
export const GET = withRouteHandler(async (_request: NextRequest) => {
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
// Get the user's chat deployments
const deployments = await db
.select()
.from(chat)
.where(and(eq(chat.userId, session.user.id), isNull(chat.archivedAt)))
return createSuccessResponse({ deployments })
} catch (error) {
logger.error('Error fetching chat deployments:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployments'), 500)
}
})
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session) {
return createErrorResponse('Unauthorized', 401)
}
const parsed = await parseRequest(
createChatContract,
request,
{},
{
validationErrorResponse: (error) =>
createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'),
}
)
if (!parsed.success) return parsed.response
const {
workflowId,
identifier,
title,
description = '',
customizations,
authType = 'public',
password,
allowedEmails = [],
outputConfigs = [],
} = parsed.data.body
if (authType === 'password' && !password) {
return createErrorResponse('Password is required when using password protection', 400)
}
if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) {
return createErrorResponse(
'At least one email or domain is required when using email access control',
400
)
}
if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) {
return createErrorResponse(
'At least one email or domain is required when using SSO access control',
400
)
}
const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
db
.select()
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1),
checkWorkflowAccessForChatCreation(workflowId, session.user.id),
])
if (existingIdentifier.length > 0) {
return createErrorResponse('Identifier already in use', 400)
}
if (!hasAccess || !workflowRecord) {
return createErrorResponse('Workflow not found or access denied', 404)
}
const result = await performChatDeploy({
workflowId,
userId: session.user.id,
identifier,
title,
description,
customizations,
authType,
password,
allowedEmails,
outputConfigs,
workspaceId: workflowRecord.workspaceId,
})
if (!result.success) {
return createErrorResponse(result.error || 'Failed to deploy chat', 500)
}
return createSuccessResponse({
id: result.chatId,
chatId: result.chatId,
chatUrl: result.chatUrl,
message: 'Chat deployment created successfully',
})
} catch (error) {
logger.error('Error creating chat deployment:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to create chat deployment'), 500)
}
})
+555
View File
@@ -0,0 +1,555 @@
/**
* Tests for chat API utils
*
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
encryptionMock,
encryptionMockFns,
loggingSessionMock,
workflowsUtilsMock,
} from '@sim/testing'
import type { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockMergeSubblockStateWithValues,
mockMergeSubBlockValues,
mockValidateAuthToken,
mockSetDeploymentAuthCookie,
mockIsEmailAllowed,
mockGetSession,
mockCheckRateLimitDirect,
mockIsWorkspaceApiExecutionEntitled,
flagState,
} = vi.hoisted(() => ({
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockSetDeploymentAuthCookie: vi.fn(),
mockIsEmailAllowed: vi.fn(),
mockGetSession: vi.fn(),
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
flagState: { isBillingEnabled: false, isFreeApiDeploymentGateEnabled: true },
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/billing/core/api-access', () => ({
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
}))
vi.mock('@/lib/core/rate-limiter', () => ({
RateLimiter: class {
checkRateLimitDirect = mockCheckRateLimitDirect
},
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
vi.mock('@/executor', () => ({
Executor: vi.fn(),
}))
vi.mock('@/serializer', () => ({
Serializer: vi.fn(),
}))
vi.mock('@sim/workflow-persistence/subblocks', () => ({
mergeSubblockStateWithValues: mockMergeSubblockStateWithValues,
mergeSubBlockValues: mockMergeSubBlockValues,
}))
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/lib/core/security/deployment', () => ({
validateAuthToken: mockValidateAuthToken,
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
isEmailAllowed: mockIsEmailAllowed,
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isDev: true,
isProd: false,
get isBillingEnabled() {
return flagState.isBillingEnabled
},
get isFreeApiDeploymentGateEnabled() {
return flagState.isFreeApiDeploymentGateEnabled
},
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
import { NextRequest } from 'next/server'
import { decryptSecret } from '@/lib/core/security/encryption'
import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
function chatRequest(origin?: string): NextRequest {
return new NextRequest('https://www.sim.ai/api/chat/abc', {
headers: origin ? { origin } : undefined,
})
}
describe('Chat API Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('process', {
...process,
env: {
...process.env,
NODE_ENV: 'development',
},
})
})
describe('Auth token utils', () => {
it('should accept valid auth cookie via validateChatAuth', async () => {
mockValidateAuthToken.mockReturnValue(true)
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue({ value: 'valid-token' }),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(mockValidateAuthToken).toHaveBeenCalledWith(
'valid-token',
'chat-id',
'password',
'encrypted-password'
)
expect(result.authorized).toBe(true)
})
it('should reject invalid auth cookie via validateChatAuth', async () => {
mockValidateAuthToken.mockReturnValue(false)
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue({ value: 'invalid-token' }),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
})
})
describe('Cookie handling', () => {
it('should delegate to setDeploymentAuthCookie', () => {
const mockResponse = {
cookies: { set: vi.fn() },
} as unknown as NextResponse
setChatAuthCookie(mockResponse, 'test-chat-id', 'password')
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
mockResponse,
'chat',
'test-chat-id',
'password',
undefined
)
})
})
describe('Chat auth validation', () => {
beforeEach(() => {
mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' })
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true })
})
it('should allow access to public chats', async () => {
const deployment = {
id: 'chat-id',
authType: 'public',
}
const mockRequest = {
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(true)
})
it('should request password auth for GET requests', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
}
const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_password')
})
it('should validate password for POST requests', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const parsedBody = {
password: 'correct-password',
}
const result = await validateChatAuth('request-id', deployment, mockRequest, parsedBody)
expect(decryptSecret).toHaveBeenCalledWith('encrypted-password')
expect(result.authorized).toBe(true)
})
it('should reject incorrect password', async () => {
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const parsedBody = {
password: 'wrong-password',
}
const result = await validateChatAuth('request-id', deployment, mockRequest, parsedBody)
expect(result.authorized).toBe(false)
expect(result.error).toBe('Invalid password')
})
it('should return 429 when the password attempt rate limit is exceeded', async () => {
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 60_000 })
const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest, {
password: 'any-guess',
})
expect(result.authorized).toBe(false)
expect(result.status).toBe(429)
expect(result.retryAfterMs).toBe(60_000)
expect(decryptSecret).not.toHaveBeenCalled()
})
it('should request email auth for email-protected chats', async () => {
const deployment = {
id: 'chat-id',
authType: 'email',
allowedEmails: ['user@example.com', '@company.com'],
}
const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_email')
})
it('should check allowed emails for email auth', async () => {
const deployment = {
id: 'chat-id',
authType: 'email',
allowedEmails: ['user@example.com', '@company.com'],
}
const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue(null),
},
} as any
mockIsEmailAllowed.mockReturnValue(true)
const result1 = await validateChatAuth('request-id', deployment, mockRequest, {
email: 'user@example.com',
})
expect(result1.authorized).toBe(false)
expect(result1.error).toBe('otp_required')
const result2 = await validateChatAuth('request-id', deployment, mockRequest, {
email: 'other@company.com',
})
expect(result2.authorized).toBe(false)
expect(result2.error).toBe('otp_required')
mockIsEmailAllowed.mockReturnValue(false)
const result3 = await validateChatAuth('request-id', deployment, mockRequest, {
email: 'user@unknown.com',
})
expect(result3.authorized).toBe(false)
expect(result3.error).toBe('Email not authorized')
})
describe('SSO auth', () => {
const ssoDeployment = {
id: 'chat-id',
authType: 'sso',
allowedEmails: ['user@example.com', '@company.com'],
}
const postRequest = {
method: 'POST',
cookies: { get: vi.fn().mockReturnValue(null) },
} as any
it('rejects when no session is present', async () => {
mockGetSession.mockResolvedValue(null)
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})
expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_sso')
})
it('ignores body-supplied email and uses the session email', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'session@example.com' } })
mockIsEmailAllowed.mockReturnValue(true)
await validateChatAuth('request-id', ssoDeployment, postRequest, {
email: 'attacker@evil.com',
input: 'hello',
})
expect(mockIsEmailAllowed).toHaveBeenCalledWith(
'session@example.com',
ssoDeployment.allowedEmails
)
})
it('authorizes execution when session email is allowlisted', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
mockIsEmailAllowed.mockReturnValue(true)
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})
expect(result.authorized).toBe(true)
})
it('rejects execution when session email is not allowlisted', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'stranger@other.com' } })
mockIsEmailAllowed.mockReturnValue(false)
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})
expect(result.authorized).toBe(false)
expect(result.error).toBe('Your email is not authorized to access this resource')
})
})
})
describe('Execution Result Processing', () => {
it.concurrent('should process logs regardless of overall success status', () => {
const executionResult = {
success: false,
output: {},
logs: [
{
blockId: 'agent1',
startedAt: '2023-01-01T00:00:00Z',
endedAt: '2023-01-01T00:00:01Z',
durationMs: 1000,
success: true,
output: { content: 'Agent 1 succeeded' },
error: undefined,
},
{
blockId: 'agent2',
startedAt: '2023-01-01T00:00:00Z',
endedAt: '2023-01-01T00:00:01Z',
durationMs: 500,
success: false,
output: null,
error: 'Agent 2 failed',
},
],
metadata: { duration: 1000 },
}
expect(executionResult.success).toBe(false)
expect(executionResult.logs).toBeDefined()
expect(executionResult.logs).toHaveLength(2)
expect(executionResult.logs[0].success).toBe(true)
expect(executionResult.logs[0].output?.content).toBe('Agent 1 succeeded')
expect(executionResult.logs[1].success).toBe(false)
expect(executionResult.logs[1].error).toBe('Agent 2 failed')
})
it.concurrent('should handle ExecutionResult vs StreamingExecution types correctly', () => {
const executionResult = {
success: true,
output: { content: 'test' },
logs: [],
metadata: { duration: 100 },
}
const directResult = executionResult
const extractedDirect = directResult
expect(extractedDirect).toBe(executionResult)
const streamingResult = {
stream: new ReadableStream(),
execution: executionResult,
}
const extractedFromStreaming =
streamingResult && typeof streamingResult === 'object' && 'execution' in streamingResult
? streamingResult.execution
: streamingResult
expect(extractedFromStreaming).toBe(executionResult)
})
})
})
describe('assertChatEmbedAllowed', () => {
beforeEach(() => {
vi.clearAllMocks()
flagState.isBillingEnabled = true
flagState.isFreeApiDeploymentGateEnabled = true
mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true)
dbChainMockFns.limit.mockResolvedValue([{ workspaceId: 'ws-1' }])
})
it('returns 403 for a cross-site origin when the owner is on the free plan', async () => {
mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false)
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res?.status).toBe(403)
})
it('allows a cross-site origin when the owner is on a paid plan', async () => {
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res).toBeNull()
})
it('returns 403 for a cross-site origin when the workflow has no active workspace', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res?.status).toBe(403)
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
it('allows a first-party *.sim.ai origin without gating', async () => {
const res = await assertChatEmbedAllowed(chatRequest('https://chat.sim.ai'), 'wf-1', 'req-1')
expect(res).toBeNull()
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
it('allows requests with no Origin header', async () => {
const res = await assertChatEmbedAllowed(chatRequest(), 'wf-1', 'req-1')
expect(res).toBeNull()
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
it('is a no-op when billing is disabled', async () => {
flagState.isBillingEnabled = false
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res).toBeNull()
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
it('is a no-op when the gate feature flag is disabled', async () => {
flagState.isFreeApiDeploymentGateEnabled = false
const res = await assertChatEmbedAllowed(
chatRequest('https://evil.example.com'),
'wf-1',
'req-1'
)
expect(res).toBeNull()
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
})
})
+154
View File
@@ -0,0 +1,154 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
import { getEnv } from '@/lib/core/config/env'
import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
import {
type DeploymentAuthResult,
validateDeploymentAuth,
} from '@/lib/core/security/deployment-auth'
import { createErrorResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatAuthUtils')
export function setChatAuthCookie(
response: NextResponse,
chatId: string,
type: string,
encryptedPassword?: string | null
): void {
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword)
}
/**
* A first-party origin is the app itself or any `*.sim.ai` host (chat subdomains
* + apex). Anything else is a third-party embed. Malformed origins are treated
* as third-party.
*/
function isFirstPartyOrigin(origin: string): boolean {
try {
const host = new URL(origin).hostname.toLowerCase()
if (host === 'sim.ai' || host.endsWith('.sim.ai')) return true
const appUrl = getEnv('NEXT_PUBLIC_APP_URL')
if (appUrl && host === new URL(appUrl).hostname.toLowerCase()) return true
return false
} catch {
return false
}
}
/**
* Gates cross-origin (embedded) chat requests behind a paid plan on hosted.
* Same-origin / SSR / first-party requests — including the chat page rendered in
* a third-party iframe, which calls the API from a `*.sim.ai` origin — are never
* gated. Returns a 403 response to short-circuit the route, or `null` to allow.
*/
export async function assertChatEmbedAllowed(
request: NextRequest,
workflowId: string,
requestId: string
): Promise<NextResponse | null> {
if (!isBillingEnabled || !isFreeApiDeploymentGateEnabled) return null
const origin = request.headers.get('origin')
if (!origin || isFirstPartyOrigin(origin)) return null
const [wf] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt)))
.limit(1)
if (!wf?.workspaceId) {
logger.warn(
`[${requestId}] Chat embed blocked: no active workspace for workflow ${workflowId}, origin=${origin}`
)
return createErrorResponse('This chat is currently unavailable', 403)
}
if (!(await isWorkspaceApiExecutionEntitled(wf.workspaceId))) {
logger.warn(`[${requestId}] Chat embed blocked: workspace on free plan, origin=${origin}`)
return createErrorResponse('Embedding this chat on external sites requires a paid plan', 403)
}
return null
}
/**
* Check if user has permission to create a chat for a specific workflow
*/
export async function checkWorkflowAccessForChatCreation(
workflowId: string,
userId: string
): Promise<{ hasAccess: boolean; workflow?: any }> {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'admin',
})
if (!authorization.workflow) {
return { hasAccess: false }
}
if (authorization.allowed) {
return { hasAccess: true, workflow: authorization.workflow }
}
return { hasAccess: false }
}
/**
* Check if user has access to view/edit/delete a specific chat
*/
export async function checkChatAccess(
chatId: string,
userId: string
): Promise<{ hasAccess: boolean; chat?: any; workspaceId?: string }> {
const chatData = await db
.select({
chat: chat,
workflowWorkspaceId: workflow.workspaceId,
})
.from(chat)
.innerJoin(workflow, eq(chat.workflowId, workflow.id))
.where(and(eq(chat.id, chatId), isNull(chat.archivedAt)))
.limit(1)
if (chatData.length === 0) {
return { hasAccess: false }
}
const { chat: chatRecord, workflowWorkspaceId } = chatData[0]
if (!workflowWorkspaceId) {
return { hasAccess: false }
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: chatRecord.workflowId,
userId,
action: 'admin',
})
return authorization.allowed
? { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId }
: { hasAccess: false }
}
/**
* Validates auth for a deployed chat. Thin wrapper over the shared
* {@link validateDeploymentAuth} with the `'chat'` cookie/rate-limit namespace.
*/
export async function validateChatAuth(
requestId: string,
deployment: any,
request: NextRequest,
parsedBody?: any
): Promise<DeploymentAuthResult> {
return validateDeploymentAuth(requestId, deployment, request, parsedBody, 'chat')
}
+59
View File
@@ -0,0 +1,59 @@
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 { identifierValidationQuerySchema } from '@/lib/api/contracts/chats'
import { getValidationErrorMessage } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatValidateAPI')
/**
* GET endpoint to validate chat identifier availability
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const { searchParams } = new URL(request.url)
const identifier = searchParams.get('identifier')
const validation = identifierValidationQuerySchema.safeParse({ identifier })
if (!validation.success) {
const errorMessage = getValidationErrorMessage(validation.error, 'Invalid identifier')
logger.warn(`Validation error: ${errorMessage}`)
if (identifier && !/^[a-z0-9-]+$/.test(identifier)) {
return createSuccessResponse({
available: false,
error: errorMessage,
})
}
return createErrorResponse(errorMessage, 400)
}
const { identifier: validatedIdentifier } = validation.data
const existingChat = await db
.select({ id: chat.id })
.from(chat)
.where(and(eq(chat.identifier, validatedIdentifier), isNull(chat.archivedAt)))
.limit(1)
const isAvailable = existingChat.length === 0
logger.debug(
`Identifier "${validatedIdentifier}" availability check: ${isAvailable ? 'available' : 'taken'}`
)
return createSuccessResponse({
available: isAvailable,
error: isAvailable ? null : 'This identifier is already in use',
})
} catch (error: any) {
logger.error('Error validating chat identifier:', error)
return createErrorResponse(error.message || 'Failed to validate identifier', 500)
}
})