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
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:
@@ -0,0 +1,883 @@
|
||||
/**
|
||||
* Tests for chat OTP API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
redisConfigMock,
|
||||
redisConfigMockFns,
|
||||
requestUtilsMockFns,
|
||||
workflowsApiUtilsMock,
|
||||
workflowsApiUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockRedisSet,
|
||||
mockRedisGet,
|
||||
mockRedisDel,
|
||||
mockRedisTtl,
|
||||
mockRedisEval,
|
||||
mockRedisClient,
|
||||
mockDbSelect,
|
||||
mockDbInsert,
|
||||
mockDbDelete,
|
||||
mockDbUpdate,
|
||||
mockSendEmail,
|
||||
mockRenderOTPEmail,
|
||||
mockSetChatAuthCookie,
|
||||
mockGetStorageMethod,
|
||||
mockZodParse,
|
||||
mockGetEnv,
|
||||
} = vi.hoisted(() => {
|
||||
const mockRedisSet = vi.fn()
|
||||
const mockRedisGet = vi.fn()
|
||||
const mockRedisDel = vi.fn()
|
||||
const mockRedisTtl = vi.fn()
|
||||
const mockRedisEval = vi.fn()
|
||||
const mockRedisClient = {
|
||||
set: mockRedisSet,
|
||||
get: mockRedisGet,
|
||||
del: mockRedisDel,
|
||||
ttl: mockRedisTtl,
|
||||
eval: mockRedisEval,
|
||||
}
|
||||
const mockDbSelect = vi.fn()
|
||||
const mockDbInsert = vi.fn()
|
||||
const mockDbDelete = vi.fn()
|
||||
const mockDbUpdate = vi.fn()
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockRenderOTPEmail = vi.fn()
|
||||
const mockSetChatAuthCookie = vi.fn()
|
||||
const mockGetStorageMethod = vi.fn()
|
||||
const mockZodParse = vi.fn()
|
||||
const mockGetEnv = vi.fn()
|
||||
|
||||
return {
|
||||
mockRedisSet,
|
||||
mockRedisGet,
|
||||
mockRedisDel,
|
||||
mockRedisTtl,
|
||||
mockRedisEval,
|
||||
mockRedisClient,
|
||||
mockDbSelect,
|
||||
mockDbInsert,
|
||||
mockDbDelete,
|
||||
mockDbUpdate,
|
||||
mockSendEmail,
|
||||
mockRenderOTPEmail,
|
||||
mockSetChatAuthCookie,
|
||||
mockGetStorageMethod,
|
||||
mockZodParse,
|
||||
mockGetEnv,
|
||||
}
|
||||
})
|
||||
|
||||
const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient
|
||||
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
|
||||
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockDbSelect,
|
||||
insert: mockDbInsert,
|
||||
delete: mockDbDelete,
|
||||
update: mockDbUpdate,
|
||||
transaction: vi.fn(async (callback: (tx: Record<string, unknown>) => unknown) => {
|
||||
return callback({
|
||||
select: mockDbSelect,
|
||||
insert: mockDbInsert,
|
||||
delete: mockDbDelete,
|
||||
update: mockDbUpdate,
|
||||
})
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((field: string, value: string) => ({ field, value, type: 'eq' })),
|
||||
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
|
||||
gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })),
|
||||
lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })),
|
||||
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/storage', () => ({
|
||||
getStorageMethod: mockGetStorageMethod,
|
||||
}))
|
||||
|
||||
const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
|
||||
mockCheckRateLimitDirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimiter: class {
|
||||
checkRateLimitDirect = mockCheckRateLimitDirect
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/messaging/email/mailer', () => ({
|
||||
sendEmail: mockSendEmail,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/emails', () => ({
|
||||
renderOTPEmail: mockRenderOTPEmail,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
isEmailAllowed: (email: string, allowedEmails: string[]) => {
|
||||
if (allowedEmails.includes(email)) return true
|
||||
const atIndex = email.indexOf('@')
|
||||
if (atIndex > 0) {
|
||||
const domain = email.substring(atIndex + 1)
|
||||
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/chat/utils', () => ({
|
||||
setChatAuthCookie: mockSetChatAuthCookie,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
getEnv: mockGetEnv,
|
||||
isTruthy: vi.fn().mockReturnValue(false),
|
||||
isFalsy: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('zod', () => {
|
||||
class ZodError extends Error {
|
||||
errors: Array<{ message: string }>
|
||||
constructor(issues: Array<{ message: string }>) {
|
||||
super('ZodError')
|
||||
this.errors = issues
|
||||
}
|
||||
}
|
||||
const chainable: Record<string, unknown> = {}
|
||||
const proxy: Record<string, unknown> = new Proxy(chainable, {
|
||||
get(target, prop) {
|
||||
if (prop === 'parse') return mockZodParse
|
||||
if (prop === 'safeParse') {
|
||||
return (data: unknown) => ({ success: true, data })
|
||||
}
|
||||
if (prop === 'then') return undefined
|
||||
if (typeof prop === 'symbol') return Reflect.get(target, prop)
|
||||
if (!(prop in target)) {
|
||||
target[prop as string] = vi.fn().mockReturnValue(proxy)
|
||||
}
|
||||
return target[prop as string]
|
||||
},
|
||||
})
|
||||
const makeChain = vi.fn(() => proxy)
|
||||
return {
|
||||
z: new Proxy(
|
||||
{ ZodError },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === 'ZodError') return ZodError
|
||||
if (typeof prop === 'symbol') return Reflect.get(target, prop)
|
||||
return makeChain
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
import { POST, PUT } from './route'
|
||||
|
||||
describe('Chat OTP API Route', () => {
|
||||
const mockEmail = 'test@example.com'
|
||||
const mockChatId = 'chat-123'
|
||||
const mockIdentifier = 'test-chat'
|
||||
const mockOTP = '123456'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.123456)
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1640995200000)
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
...crypto,
|
||||
randomUUID: vi.fn().mockReturnValue('test-uuid-1234'),
|
||||
})
|
||||
|
||||
mockGetRedisClient.mockReturnValue(mockRedisClient)
|
||||
mockRedisSet.mockResolvedValue('OK')
|
||||
mockRedisGet.mockResolvedValue(null)
|
||||
mockRedisDel.mockResolvedValue(1)
|
||||
mockRedisTtl.mockResolvedValue(600)
|
||||
|
||||
const createDbChain = (result: unknown) => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(result),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDbSelect.mockImplementation(() => createDbChain([]))
|
||||
mockDbInsert.mockImplementation(() => ({
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
mockDbDelete.mockImplementation(() => ({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
mockDbUpdate.mockImplementation(() => ({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}))
|
||||
|
||||
mockGetStorageMethod.mockReturnValue('redis')
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true })
|
||||
mockRenderOTPEmail.mockResolvedValue('<html>OTP Email</html>')
|
||||
|
||||
mockCreateSuccessResponse.mockImplementation((data: unknown) => ({
|
||||
json: () => Promise.resolve(data),
|
||||
status: 200,
|
||||
}))
|
||||
mockCreateErrorResponse.mockImplementation((message: string, status: number) => ({
|
||||
json: () => Promise.resolve({ error: message }),
|
||||
status,
|
||||
}))
|
||||
|
||||
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123')
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4')
|
||||
|
||||
mockCheckRateLimitDirect.mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
})
|
||||
|
||||
mockZodParse.mockImplementation((data: unknown) => data)
|
||||
|
||||
mockGetEnv.mockReturnValue('http://localhost:3000')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('POST - Store OTP (Redis path)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStorageMethod.mockReturnValue('redis')
|
||||
})
|
||||
|
||||
it('should store OTP in Redis when storage method is redis', async () => {
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
allowedEmails: [mockEmail],
|
||||
title: 'Test Chat',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: mockEmail }),
|
||||
})
|
||||
|
||||
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockRedisSet).toHaveBeenCalledWith(
|
||||
`otp:${mockEmail}:${mockChatId}`,
|
||||
expect.any(String),
|
||||
'EX',
|
||||
900 // 15 minutes
|
||||
)
|
||||
|
||||
expect(mockDbInsert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST - Rate limiting', () => {
|
||||
const buildDeploymentSelect = () =>
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
allowedEmails: [mockEmail],
|
||||
title: 'Test Chat',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
it('returns 429 with Retry-After when IP rate limit is exceeded', async () => {
|
||||
mockCheckRateLimitDirect.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(Date.now() + 900_000),
|
||||
retryAfterMs: 900_000,
|
||||
})
|
||||
|
||||
const headerSet = vi.fn()
|
||||
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
|
||||
json: () => Promise.resolve({ error: message }),
|
||||
status,
|
||||
headers: { set: headerSet },
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: mockEmail }),
|
||||
})
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ identifier: mockIdentifier }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(429)
|
||||
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
expect(mockDbSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 429 with Retry-After when email rate limit is exceeded', async () => {
|
||||
mockCheckRateLimitDirect
|
||||
.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(Date.now() + 900_000),
|
||||
retryAfterMs: 900_000,
|
||||
})
|
||||
|
||||
const headerSet = vi.fn()
|
||||
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
|
||||
json: () => Promise.resolve({ error: message }),
|
||||
status,
|
||||
headers: { set: headerSet },
|
||||
}))
|
||||
|
||||
buildDeploymentSelect()
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: mockEmail }),
|
||||
})
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ identifier: mockIdentifier }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(429)
|
||||
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to refill interval when retryAfterMs is missing', async () => {
|
||||
mockCheckRateLimitDirect.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(Date.now() + 900_000),
|
||||
})
|
||||
|
||||
const headerSet = vi.fn()
|
||||
mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
|
||||
json: () => Promise.resolve({ error: message }),
|
||||
status,
|
||||
headers: { set: headerSet },
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: mockEmail }),
|
||||
})
|
||||
|
||||
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
|
||||
})
|
||||
|
||||
it('folds spoofed `unknown` client IPs into a single shared bucket', async () => {
|
||||
requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown')
|
||||
buildDeploymentSelect()
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: mockEmail }),
|
||||
})
|
||||
|
||||
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^chat-otp:ip:.*:unknown$/),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCheckRateLimitDirect).toHaveBeenCalledWith(
|
||||
expect.stringContaining('chat-otp:email:'),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST - Store OTP (Database path)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStorageMethod.mockReturnValue('database')
|
||||
mockGetRedisClient.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('should store OTP in database when storage method is database', async () => {
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
allowedEmails: [mockEmail],
|
||||
title: 'Test Chat',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockInsertValues = vi.fn().mockResolvedValue(undefined)
|
||||
mockDbInsert.mockImplementationOnce(() => ({
|
||||
values: mockInsertValues,
|
||||
}))
|
||||
|
||||
const mockDeleteWhere = vi.fn().mockResolvedValue(undefined)
|
||||
mockDbDelete.mockImplementation(() => ({
|
||||
where: mockDeleteWhere,
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: mockEmail }),
|
||||
})
|
||||
|
||||
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockDbDelete).toHaveBeenCalled()
|
||||
|
||||
expect(mockDbInsert).toHaveBeenCalled()
|
||||
expect(mockInsertValues).toHaveBeenCalledWith({
|
||||
id: expect.any(String),
|
||||
identifier: `chat-otp:${mockChatId}:${mockEmail}`,
|
||||
value: expect.any(String),
|
||||
expiresAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
})
|
||||
|
||||
expect(mockRedisSet).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT - Verify OTP (Redis path)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStorageMethod.mockReturnValue('redis')
|
||||
mockRedisGet.mockResolvedValue(`${mockOTP}:0`)
|
||||
})
|
||||
|
||||
it('should retrieve OTP from Redis and verify successfully', async () => {
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
|
||||
})
|
||||
|
||||
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
|
||||
expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
|
||||
expect(mockDbSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT - Verify OTP (authType re-check)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStorageMethod.mockReturnValue('redis')
|
||||
mockRedisGet.mockResolvedValue(`${mockOTP}:0`)
|
||||
})
|
||||
|
||||
it('rejects verification when the chat has switched away from email auth', async () => {
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'password',
|
||||
password: 'encrypted-password',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
|
||||
})
|
||||
|
||||
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||
'This chat does not use email authentication',
|
||||
400
|
||||
)
|
||||
expect(mockRedisGet).not.toHaveBeenCalled()
|
||||
expect(mockSetChatAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT - Verify OTP (Database path)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStorageMethod.mockReturnValue('database')
|
||||
mockGetRedisClient.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('should retrieve OTP from database and verify successfully', async () => {
|
||||
let selectCallCount = 0
|
||||
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockImplementation(() => {
|
||||
selectCallCount++
|
||||
if (selectCallCount === 1) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
},
|
||||
])
|
||||
}
|
||||
return Promise.resolve([
|
||||
{
|
||||
value: `${mockOTP}:0`,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
])
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockDeleteWhere = vi.fn().mockResolvedValue(undefined)
|
||||
mockDbDelete.mockImplementation(() => ({
|
||||
where: mockDeleteWhere,
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
|
||||
})
|
||||
|
||||
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockDbSelect).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(mockDbDelete).toHaveBeenCalled()
|
||||
|
||||
expect(mockRedisGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject expired OTP from database', async () => {
|
||||
let selectCallCount = 0
|
||||
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockImplementation(() => {
|
||||
selectCallCount++
|
||||
if (selectCallCount === 1) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
},
|
||||
])
|
||||
}
|
||||
return Promise.resolve([])
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
|
||||
})
|
||||
|
||||
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||
'No verification code found, request a new one',
|
||||
400
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE OTP (Redis path)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStorageMethod.mockReturnValue('redis')
|
||||
})
|
||||
|
||||
it('should delete OTP from Redis after verification', async () => {
|
||||
mockRedisGet.mockResolvedValue(`${mockOTP}:0`)
|
||||
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
|
||||
})
|
||||
|
||||
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
|
||||
expect(mockDbDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE OTP (Database path)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStorageMethod.mockReturnValue('database')
|
||||
mockGetRedisClient.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('should delete OTP from database after verification', async () => {
|
||||
let selectCallCount = 0
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockImplementation(() => {
|
||||
selectCallCount++
|
||||
if (selectCallCount === 1) {
|
||||
return Promise.resolve([{ id: mockChatId, authType: 'email' }])
|
||||
}
|
||||
return Promise.resolve([
|
||||
{ value: `${mockOTP}:0`, expiresAt: new Date(Date.now() + 10 * 60 * 1000) },
|
||||
])
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockDeleteWhere = vi.fn().mockResolvedValue(undefined)
|
||||
mockDbDelete.mockImplementation(() => ({
|
||||
where: mockDeleteWhere,
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
|
||||
})
|
||||
|
||||
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockDbDelete).toHaveBeenCalled()
|
||||
expect(mockRedisDel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Brute-force protection', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStorageMethod.mockReturnValue('redis')
|
||||
})
|
||||
|
||||
it('should atomically increment attempts on wrong OTP', async () => {
|
||||
mockRedisGet.mockResolvedValue('654321:0')
|
||||
mockRedisEval.mockResolvedValue('654321:1')
|
||||
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: 'wrong1' }),
|
||||
})
|
||||
|
||||
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockRedisEval).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
1,
|
||||
`otp:${mockEmail}:${mockChatId}`,
|
||||
5
|
||||
)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Invalid verification code', 400)
|
||||
})
|
||||
|
||||
it('should invalidate OTP and return 429 after max failed attempts', async () => {
|
||||
mockRedisGet.mockResolvedValue('654321:4')
|
||||
mockRedisEval.mockResolvedValue('LOCKED')
|
||||
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: 'wrong5' }),
|
||||
})
|
||||
|
||||
await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockRedisEval).toHaveBeenCalled()
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||
'Too many failed attempts. Please request a new code.',
|
||||
429
|
||||
)
|
||||
})
|
||||
|
||||
it('should store OTP with zero attempts on generation', async () => {
|
||||
mockDbSelect.mockImplementationOnce(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
allowedEmails: [mockEmail],
|
||||
title: 'Test Chat',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: mockEmail }),
|
||||
})
|
||||
|
||||
await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockRedisSet).toHaveBeenCalledWith(
|
||||
`otp:${mockEmail}:${mockChatId}`,
|
||||
expect.stringMatching(/^\d{6}:0$/),
|
||||
'EX',
|
||||
900
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Behavior consistency between Redis and Database', () => {
|
||||
it('should have same behavior for missing OTP in both storage methods', async () => {
|
||||
mockGetStorageMethod.mockReturnValue('redis')
|
||||
mockRedisGet.mockResolvedValue(null)
|
||||
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
|
||||
})
|
||||
|
||||
await PUT(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||
'No verification code found, request a new one',
|
||||
400
|
||||
)
|
||||
})
|
||||
|
||||
it('should have same OTP expiry time in both storage methods', async () => {
|
||||
const OTP_EXPIRY = 15 * 60
|
||||
|
||||
mockGetStorageMethod.mockReturnValue('redis')
|
||||
|
||||
mockDbSelect.mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: mockChatId,
|
||||
authType: 'email',
|
||||
allowedEmails: [mockEmail],
|
||||
title: 'Test Chat',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: mockEmail }),
|
||||
})
|
||||
|
||||
await POST(requestRedis, { params: Promise.resolve({ identifier: mockIdentifier }) })
|
||||
|
||||
expect(mockRedisSet).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
'EX',
|
||||
OTP_EXPIRY
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { renderOTPEmail } from '@/components/emails'
|
||||
import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import {
|
||||
decodeOTPValue,
|
||||
deleteOTP,
|
||||
generateOTP,
|
||||
getOTP,
|
||||
incrementOTPAttempts,
|
||||
MAX_OTP_ATTEMPTS,
|
||||
OTP_EMAIL_RATE_LIMIT,
|
||||
OTP_IP_RATE_LIMIT,
|
||||
storeOTP,
|
||||
} from '@/lib/core/security/otp'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { setChatAuthCookie } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatOtpAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const { identifier } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`chat-otp:ip:${identifier}:${ip}`,
|
||||
OTP_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
|
||||
const retryAfter = Math.ceil(
|
||||
(ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
|
||||
)
|
||||
const response = createErrorResponse('Too many requests. Please try again later.', 429)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return response
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(requestChatEmailOtpContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email } = parsed.data.body
|
||||
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
title: chat.title,
|
||||
})
|
||||
.from(chat)
|
||||
.where(
|
||||
and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (deployment.authType !== 'email') {
|
||||
return createErrorResponse('This chat does not use email authentication', 400)
|
||||
}
|
||||
|
||||
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
|
||||
? deployment.allowedEmails
|
||||
: []
|
||||
|
||||
if (!isEmailAllowed(email, allowedEmails)) {
|
||||
return createErrorResponse('Email not authorized for this chat', 403)
|
||||
}
|
||||
|
||||
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`chat-otp:email:${deployment.id}:${email.toLowerCase()}`,
|
||||
OTP_EMAIL_RATE_LIMIT
|
||||
)
|
||||
if (!emailRateLimit.allowed) {
|
||||
logger.warn(
|
||||
`[${requestId}] OTP email rate limit exceeded for ${email} on chat ${deployment.id}`
|
||||
)
|
||||
const retryAfter = Math.ceil(
|
||||
(emailRateLimit.retryAfterMs ?? OTP_EMAIL_RATE_LIMIT.refillIntervalMs) / 1000
|
||||
)
|
||||
const response = createErrorResponse(
|
||||
'Too many verification code requests. Please try again later.',
|
||||
429
|
||||
)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return response
|
||||
}
|
||||
|
||||
const otp = generateOTP()
|
||||
await storeOTP('chat', deployment.id, email, otp)
|
||||
|
||||
const emailHtml = await renderOTPEmail(
|
||||
otp,
|
||||
email,
|
||||
'email-verification',
|
||||
deployment.title || 'Chat'
|
||||
)
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: `Verification code for ${deployment.title || 'Chat'}`,
|
||||
html: emailHtml,
|
||||
})
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
|
||||
return createErrorResponse('Failed to send verification email', 500)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`)
|
||||
return createSuccessResponse({ message: 'Verification code sent' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing OTP request:`, error)
|
||||
return createErrorResponse('Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const { identifier } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(verifyChatEmailOtpContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { email, otp } = parsed.data.body
|
||||
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
authType: chat.authType,
|
||||
password: chat.password,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
})
|
||||
.from(chat)
|
||||
.where(
|
||||
and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (deployment.authType !== 'email') {
|
||||
return createErrorResponse('This chat does not use email authentication', 400)
|
||||
}
|
||||
|
||||
const storedValue = await getOTP('chat', deployment.id, email)
|
||||
if (!storedValue) {
|
||||
return createErrorResponse('No verification code found, request a new one', 400)
|
||||
}
|
||||
|
||||
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
|
||||
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await deleteOTP('chat', deployment.id, email)
|
||||
logger.warn(`[${requestId}] OTP already locked out for ${email}`)
|
||||
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
|
||||
}
|
||||
|
||||
if (storedOTP !== otp) {
|
||||
const result = await incrementOTPAttempts('chat', deployment.id, email, storedValue)
|
||||
if (result === 'locked') {
|
||||
logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
|
||||
return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
|
||||
}
|
||||
return createErrorResponse('Invalid verification code', 400)
|
||||
}
|
||||
|
||||
await deleteOTP('chat', deployment.id, email)
|
||||
|
||||
const response = createSuccessResponse({
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
outputConfigs: deployment.outputConfigs,
|
||||
})
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error verifying OTP:`, error)
|
||||
return createErrorResponse('Failed to process request', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Tests for chat identifier API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
encryptionMock,
|
||||
executionPreprocessingMock,
|
||||
executionPreprocessingMockFns,
|
||||
loggingSessionMock,
|
||||
workflowsApiUtilsMock,
|
||||
workflowsApiUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Creates a mock NextRequest with cookies support for testing.
|
||||
*/
|
||||
function createMockNextRequest(
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
url = 'http://localhost:3000/api/test'
|
||||
): any {
|
||||
const headersObj = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
})
|
||||
|
||||
const parsedUrl = new URL(url)
|
||||
|
||||
return {
|
||||
method,
|
||||
headers: headersObj,
|
||||
nextUrl: parsedUrl,
|
||||
cookies: {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
json:
|
||||
body !== undefined
|
||||
? vi.fn().mockResolvedValue(body)
|
||||
: vi.fn().mockRejectedValue(new Error('No body')),
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
const createMockStream = () => {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"blockId":"agent-1","chunk":"Hello"}\n\n')
|
||||
)
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"blockId":"agent-1","chunk":" world"}\n\n')
|
||||
)
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"event":"final","data":{"success":true}}\n\n')
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
mockValidateChatAuth,
|
||||
mockSetChatAuthCookie,
|
||||
mockValidateAuthToken,
|
||||
mockAssertChatEmbedAllowed,
|
||||
} = vi.hoisted(() => ({
|
||||
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
|
||||
mockSetChatAuthCookie: vi.fn(),
|
||||
mockValidateAuthToken: vi.fn().mockReturnValue(false),
|
||||
mockAssertChatEmbedAllowed: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
|
||||
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
...dbChainMock,
|
||||
chat: {},
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
validateAuthToken: mockValidateAuthToken,
|
||||
setDeploymentAuthCookie: vi.fn(),
|
||||
isEmailAllowed: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/chat/utils', () => ({
|
||||
validateChatAuth: mockValidateChatAuth,
|
||||
setChatAuthCookie: mockSetChatAuthCookie,
|
||||
assertChatEmbedAllowed: mockAssertChatEmbedAllowed,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
|
||||
|
||||
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
|
||||
|
||||
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
|
||||
|
||||
vi.mock('@/lib/workflows/streaming/streaming', () => ({
|
||||
createStreamingResponse: vi.fn().mockImplementation(async () => createMockStream()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
|
||||
executeWorkflow: vi.fn().mockResolvedValue({ success: true, output: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/sse', () => ({
|
||||
SSE_HEADERS: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
|
||||
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||
import { GET, POST } from '@/app/api/chat/[identifier]/route'
|
||||
|
||||
describe('Chat Identifier API Route', () => {
|
||||
const mockChatResult = [
|
||||
{
|
||||
id: 'chat-id',
|
||||
workflowId: 'workflow-id',
|
||||
userId: 'user-id',
|
||||
isActive: true,
|
||||
authType: 'public',
|
||||
title: 'Test Chat',
|
||||
description: 'Test chat description',
|
||||
customizations: {
|
||||
welcomeMessage: 'Welcome to the test chat',
|
||||
primaryColor: '#000000',
|
||||
},
|
||||
outputConfigs: [{ blockId: 'block-1', path: 'output' }],
|
||||
},
|
||||
]
|
||||
|
||||
const mockWorkflowResult = [
|
||||
{
|
||||
isDeployed: true,
|
||||
state: {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
},
|
||||
deployedState: {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValue({
|
||||
success: true,
|
||||
actorUserId: 'test-user-id',
|
||||
workflowRecord: {
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
isDeployed: true,
|
||||
workspaceId: 'test-workspace-id',
|
||||
variables: {},
|
||||
},
|
||||
userSubscription: {
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
},
|
||||
rateLimitInfo: {
|
||||
allowed: true,
|
||||
remaining: 100,
|
||||
resetAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
mockValidateChatAuth.mockResolvedValue({ authorized: true })
|
||||
mockValidateAuthToken.mockReturnValue(false)
|
||||
mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: code || 'Error',
|
||||
message,
|
||||
}),
|
||||
{ status }
|
||||
)
|
||||
})
|
||||
mockCreateSuccessResponse.mockImplementation((data: unknown) => {
|
||||
return new Response(JSON.stringify(data), { status: 200 })
|
||||
})
|
||||
|
||||
dbChainMockFns.select.mockImplementation((fields: Record<string, unknown>) => {
|
||||
if (fields && fields.isDeployed !== undefined) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue(mockWorkflowResult),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue(mockChatResult),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET endpoint', () => {
|
||||
it('should return chat info for a valid identifier', async () => {
|
||||
const req = createMockNextRequest('GET')
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('id', 'chat-id')
|
||||
expect(data).toHaveProperty('title', 'Test Chat')
|
||||
expect(data).toHaveProperty('description', 'Test chat description')
|
||||
expect(data).toHaveProperty('customizations')
|
||||
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
|
||||
})
|
||||
|
||||
it('should return 403 when embedding is blocked for a cross-origin caller', async () => {
|
||||
mockAssertChatEmbedAllowed.mockResolvedValueOnce(
|
||||
NextResponse.json(
|
||||
{ error: 'Embedding this chat on external sites requires a paid plan' },
|
||||
{
|
||||
status: 403,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const req = createMockNextRequest('GET', undefined, { origin: 'https://evil.example.com' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent identifier', async () => {
|
||||
dbChainMockFns.select.mockImplementation(() => {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('GET')
|
||||
const params = Promise.resolve({ identifier: 'nonexistent' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Chat not found')
|
||||
})
|
||||
|
||||
it('should return 403 for inactive chat', async () => {
|
||||
dbChainMockFns.select.mockImplementation(() => {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: 'chat-id',
|
||||
isActive: false,
|
||||
authType: 'public',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('GET')
|
||||
const params = Promise.resolve({ identifier: 'inactive-chat' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'This chat is currently unavailable')
|
||||
})
|
||||
|
||||
it('should return 401 when authentication is required', async () => {
|
||||
mockValidateChatAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'auth_required_password',
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('GET')
|
||||
const params = Promise.resolve({ identifier: 'password-protected-chat' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'auth_required_password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST endpoint', () => {
|
||||
it('should return 403 when embedding is blocked for a cross-origin caller', async () => {
|
||||
mockAssertChatEmbedAllowed.mockResolvedValueOnce(
|
||||
NextResponse.json(
|
||||
{ error: 'Embedding this chat on external sites requires a paid plan' },
|
||||
{
|
||||
status: 403,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const req = createMockNextRequest(
|
||||
'POST',
|
||||
{ input: 'Hello' },
|
||||
{ origin: 'https://evil.example.com' }
|
||||
)
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should return chat config on successful authentication', async () => {
|
||||
const req = createMockNextRequest('POST', { password: 'test-password' })
|
||||
const params = Promise.resolve({ identifier: 'password-protected-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('id', 'chat-id')
|
||||
expect(data).toHaveProperty('title', 'Test Chat')
|
||||
expect(data).toHaveProperty('customizations')
|
||||
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
|
||||
|
||||
expect(mockSetChatAuthCookie).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 400 for requests without input', async () => {
|
||||
const req = createMockNextRequest('POST', {})
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'No input provided')
|
||||
})
|
||||
|
||||
it('should return 401 for unauthorized access', async () => {
|
||||
mockValidateChatAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'Authentication required',
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('POST', { input: 'Hello' })
|
||||
const params = Promise.resolve({ identifier: 'protected-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Authentication required')
|
||||
})
|
||||
|
||||
it('should return 503 when workflow is not available', async () => {
|
||||
vi.mocked(preprocessExecution).mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Workflow is not deployed',
|
||||
statusCode: 403,
|
||||
logCreated: false,
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('POST', { input: 'Hello' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Workflow is not deployed')
|
||||
})
|
||||
|
||||
it('should return streaming response for valid chat messages', async () => {
|
||||
const req = createMockNextRequest('POST', {
|
||||
input: 'Hello world',
|
||||
conversationId: 'conv-123',
|
||||
})
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('text/event-stream')
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-cache')
|
||||
expect(response.headers.get('Connection')).toBe('keep-alive')
|
||||
|
||||
expect(createStreamingResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executeFn: expect.any(Function),
|
||||
streamConfig: expect.objectContaining({
|
||||
isSecureMode: true,
|
||||
workflowTriggerType: 'chat',
|
||||
}),
|
||||
})
|
||||
)
|
||||
}, 10000)
|
||||
|
||||
it('should handle streaming response body correctly', async () => {
|
||||
const req = createMockNextRequest('POST', { input: 'Hello world' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body).toBeInstanceOf(ReadableStream)
|
||||
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader()
|
||||
const { value, done } = await reader.read()
|
||||
|
||||
if (!done && value) {
|
||||
const chunk = new TextDecoder().decode(value)
|
||||
expect(chunk).toMatch(/^data: /)
|
||||
}
|
||||
|
||||
reader.releaseLock()
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle workflow execution errors gracefully', async () => {
|
||||
vi.mocked(createStreamingResponse).mockImplementationOnce(async () => {
|
||||
throw new Error('Execution failed')
|
||||
})
|
||||
|
||||
const req = createMockNextRequest('POST', { input: 'Trigger error' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Execution failed')
|
||||
})
|
||||
|
||||
it('should handle invalid JSON in request body', async () => {
|
||||
const req = {
|
||||
method: 'POST',
|
||||
headers: new Headers(),
|
||||
nextUrl: new URL('http://localhost:3000/api/test'),
|
||||
cookies: { get: vi.fn().mockReturnValue(undefined) },
|
||||
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
} as any
|
||||
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
expect(data).toHaveProperty('message', 'Invalid request body')
|
||||
})
|
||||
|
||||
it('should pass conversationId to streaming execution when provided', async () => {
|
||||
const req = createMockNextRequest('POST', {
|
||||
input: 'Hello world',
|
||||
conversationId: 'test-conversation-123',
|
||||
})
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
await POST(req, { params })
|
||||
|
||||
expect(createStreamingResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executeFn: expect.any(Function),
|
||||
streamConfig: expect.objectContaining({
|
||||
workflowTriggerType: 'chat',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing conversationId gracefully', async () => {
|
||||
const req = createMockNextRequest('POST', { input: 'Hello world' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
await POST(req, { params })
|
||||
|
||||
expect(createStreamingResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executeFn: expect.any(Function),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { deployedChatPostContract } from '@/lib/api/contracts/chats'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
|
||||
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { validateAuthToken } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { ChatFiles } from '@/lib/uploads'
|
||||
import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatIdentifierAPI')
|
||||
|
||||
interface ChatConfigSource {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
customizations: unknown
|
||||
authType: string | null
|
||||
outputConfigs: unknown
|
||||
}
|
||||
|
||||
function toChatConfigResponse(deployment: ChatConfigSource) {
|
||||
return {
|
||||
id: deployment.id,
|
||||
title: deployment.title,
|
||||
description: deployment.description,
|
||||
customizations: deployment.customizations,
|
||||
authType: deployment.authType,
|
||||
outputConfigs: deployment.outputConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const { identifier } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const ticket = tryAdmit()
|
||||
if (!ticket) {
|
||||
return admissionRejectedResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(deployedChatPostContract, request, context, {
|
||||
maxBodyBytes: CHAT_MAX_REQUEST_BYTES,
|
||||
validationErrorResponse: (err) => {
|
||||
const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')
|
||||
return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR')
|
||||
},
|
||||
invalidJsonResponse: () => createErrorResponse('Invalid request body', 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const parsedBody = parsed.data.body
|
||||
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
workflowId: chat.workflowId,
|
||||
userId: chat.userId,
|
||||
isActive: chat.isActive,
|
||||
authType: chat.authType,
|
||||
password: chat.password,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (!deployment.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`
|
||||
)
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const executionId = generateId()
|
||||
const loggingSession = new LoggingSession(
|
||||
deployment.workflowId,
|
||||
executionId,
|
||||
'chat',
|
||||
requestId
|
||||
)
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: deployment.userId,
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
error: {
|
||||
message: 'This chat is currently unavailable. The chat has been disabled.',
|
||||
stackTrace: undefined,
|
||||
},
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId)
|
||||
if (embedBlock) return embedBlock
|
||||
|
||||
const authResult = await validateChatAuth(requestId, deployment, request, parsedBody)
|
||||
if (!authResult.authorized) {
|
||||
const response = createErrorResponse(
|
||||
authResult.error || 'Authentication required',
|
||||
authResult.status || 401
|
||||
)
|
||||
if (authResult.status === 429 && authResult.retryAfterMs !== undefined) {
|
||||
response.headers.set('Retry-After', String(Math.ceil(authResult.retryAfterMs / 1000)))
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const { input, password, email, conversationId, files } = parsedBody
|
||||
|
||||
if ((password || email) && !input) {
|
||||
const response = createSuccessResponse(toChatConfigResponse(deployment))
|
||||
|
||||
if (deployment.authType !== 'sso') {
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
if (!input && (!files || files.length === 0)) {
|
||||
return createErrorResponse('No input provided', 400)
|
||||
}
|
||||
|
||||
const executionId = generateId()
|
||||
|
||||
const loggingSession = new LoggingSession(
|
||||
deployment.workflowId,
|
||||
executionId,
|
||||
'chat',
|
||||
requestId
|
||||
)
|
||||
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId: deployment.workflowId,
|
||||
userId: deployment.userId,
|
||||
triggerType: 'chat',
|
||||
executionId,
|
||||
requestId,
|
||||
checkRateLimit: true,
|
||||
checkDeployment: true,
|
||||
loggingSession,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
|
||||
return createErrorResponse(
|
||||
preprocessResult.error?.message || 'Failed to process request',
|
||||
preprocessResult.error?.statusCode || 500
|
||||
)
|
||||
}
|
||||
|
||||
const { actorUserId, workflowRecord } = preprocessResult
|
||||
const workspaceOwnerId = actorUserId!
|
||||
const workspaceId = workflowRecord?.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
|
||||
// preprocessExecution reserved a billing concurrency slot; release it on
|
||||
// this early exit since no LoggingSession will finalize to free it.
|
||||
await releaseExecutionSlot(executionId)
|
||||
return createErrorResponse('Workflow has no associated workspace', 500)
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedOutputs: string[] = []
|
||||
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
|
||||
for (const config of deployment.outputConfigs) {
|
||||
const outputId = config.path
|
||||
? `${config.blockId}_${config.path}`
|
||||
: `${config.blockId}_content`
|
||||
selectedOutputs.push(outputId)
|
||||
}
|
||||
}
|
||||
|
||||
const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming')
|
||||
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
|
||||
const { SSE_HEADERS } = await import('@/lib/core/utils/sse')
|
||||
|
||||
const workflowInput: any = { input, conversationId }
|
||||
if (files && Array.isArray(files) && files.length > 0) {
|
||||
const executionContext = {
|
||||
workspaceId,
|
||||
workflowId: deployment.workflowId,
|
||||
executionId,
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadedFiles = await ChatFiles.processChatFiles(
|
||||
files,
|
||||
executionContext,
|
||||
requestId,
|
||||
deployment.userId
|
||||
)
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
workflowInput.files = uploadedFiles
|
||||
logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`)
|
||||
}
|
||||
} catch (fileError: any) {
|
||||
logger.error(`[${requestId}] Failed to process chat files:`, fileError)
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: workspaceOwnerId,
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
await loggingSession.safeCompleteWithError({
|
||||
error: {
|
||||
message: `File upload failed: ${fileError.message || 'Unable to process uploaded files'}`,
|
||||
stackTrace: fileError.stack,
|
||||
},
|
||||
traceSpans: [],
|
||||
})
|
||||
|
||||
throw fileError
|
||||
}
|
||||
}
|
||||
|
||||
const workflowForExecution = {
|
||||
id: deployment.workflowId,
|
||||
userId: deployment.userId,
|
||||
workspaceId,
|
||||
isDeployed: workflowRecord?.isDeployed ?? false,
|
||||
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,
|
||||
}
|
||||
|
||||
const stream = await createStreamingResponse({
|
||||
requestId,
|
||||
streamConfig: {
|
||||
selectedOutputs,
|
||||
isSecureMode: true,
|
||||
workflowTriggerType: 'chat',
|
||||
},
|
||||
executionId,
|
||||
workspaceId,
|
||||
workflowId: deployment.workflowId,
|
||||
userId: workspaceOwnerId,
|
||||
executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
|
||||
executeWorkflow(
|
||||
workflowForExecution,
|
||||
requestId,
|
||||
workflowInput,
|
||||
workspaceOwnerId,
|
||||
{
|
||||
enabled: true,
|
||||
selectedOutputs,
|
||||
isSecureMode: true,
|
||||
workflowTriggerType: 'chat',
|
||||
onStream,
|
||||
onBlockComplete,
|
||||
skipLoggingComplete: true,
|
||||
abortSignal,
|
||||
executionMode: 'stream',
|
||||
},
|
||||
executionId
|
||||
),
|
||||
})
|
||||
|
||||
const streamResponse = new NextResponse(stream, {
|
||||
status: 200,
|
||||
headers: SSE_HEADERS,
|
||||
})
|
||||
return streamResponse
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error processing chat request:`, error)
|
||||
// Setup failed before the workflow stream took over slot release;
|
||||
// free the reserved billing slot (idempotent if already released).
|
||||
await releaseExecutionSlot(executionId)
|
||||
return createErrorResponse(error.message || 'Failed to process request', 500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error processing chat request:`, error)
|
||||
return createErrorResponse(error.message || 'Failed to process request', 500)
|
||||
} finally {
|
||||
ticket.release()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => {
|
||||
const { identifier } = await params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
isActive: chat.isActive,
|
||||
workflowId: chat.workflowId,
|
||||
authType: chat.authType,
|
||||
password: chat.password,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
const deployment = deploymentResult[0]
|
||||
|
||||
if (!deployment.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
return createErrorResponse('This chat is currently unavailable', 403)
|
||||
}
|
||||
|
||||
const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId)
|
||||
if (embedBlock) return embedBlock
|
||||
|
||||
const cookieName = `chat_auth_${deployment.id}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
|
||||
if (
|
||||
deployment.authType !== 'public' &&
|
||||
deployment.authType !== 'sso' &&
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password)
|
||||
) {
|
||||
return createSuccessResponse(toChatConfigResponse(deployment))
|
||||
}
|
||||
|
||||
const authResult = await validateChatAuth(requestId, deployment, request)
|
||||
if (!authResult.authorized) {
|
||||
logger.info(
|
||||
`[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}`
|
||||
)
|
||||
return createErrorResponse(authResult.error || 'Authentication required', 401)
|
||||
}
|
||||
|
||||
return createSuccessResponse(toChatConfigResponse(deployment))
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching chat info:`, error)
|
||||
return createErrorResponse(error.message || 'Failed to fetch chat information', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { chatSSOContract } from '@/lib/api/contracts/chats'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatSSOAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 20,
|
||||
refillRate: 20,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`chat-sso:ip:${ip}`,
|
||||
SSO_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
|
||||
const retryAfter = Math.ceil(
|
||||
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
|
||||
)
|
||||
const response = createErrorResponse('Too many requests. Please try again later.', 429)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return response
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(chatSSOContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { identifier } = parsed.data.params
|
||||
const { email } = parsed.data.body
|
||||
|
||||
const [deployment] = await db
|
||||
.select({
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
isActive: chat.isActive,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!deployment || !deployment.isActive) {
|
||||
logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
|
||||
return createErrorResponse('Chat not found', 404)
|
||||
}
|
||||
|
||||
if (deployment.authType !== 'sso') {
|
||||
return createErrorResponse('Chat is not configured for SSO authentication', 400)
|
||||
}
|
||||
|
||||
const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])
|
||||
|
||||
return createSuccessResponse({ eligible })
|
||||
}
|
||||
)
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user