diff --git a/apps/sim/app/api/chat/edit/[id]/route.test.ts b/apps/sim/app/api/chat/edit/[id]/route.test.ts new file mode 100644 index 000000000..feee2f6d1 --- /dev/null +++ b/apps/sim/app/api/chat/edit/[id]/route.test.ts @@ -0,0 +1,396 @@ +import { NextRequest } from 'next/server' +/** + * Tests for chat edit API route + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('Chat Edit API Route', () => { + const mockSelect = vi.fn() + const mockFrom = vi.fn() + const mockWhere = vi.fn() + const mockLimit = vi.fn() + const mockUpdate = vi.fn() + const mockSet = vi.fn() + const mockDelete = vi.fn() + + const mockCreateSuccessResponse = vi.fn() + const mockCreateErrorResponse = vi.fn() + const mockEncryptSecret = vi.fn() + const mockCheckChatAccess = vi.fn() + + beforeEach(() => { + vi.resetModules() + + mockSelect.mockReturnValue({ from: mockFrom }) + mockFrom.mockReturnValue({ where: mockWhere }) + mockWhere.mockReturnValue({ limit: mockLimit }) + mockUpdate.mockReturnValue({ set: mockSet }) + mockSet.mockReturnValue({ where: mockWhere }) + mockDelete.mockReturnValue({ where: mockWhere }) + + vi.doMock('@/db', () => ({ + db: { + select: mockSelect, + update: mockUpdate, + delete: mockDelete, + }, + })) + + vi.doMock('@/db/schema', () => ({ + chat: { id: 'id', subdomain: 'subdomain', userId: 'userId' }, + })) + + vi.doMock('@/lib/logs/console-logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + })) + + vi.doMock('@/app/api/workflows/utils', () => ({ + createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }), + createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }), + })) + + vi.doMock('@/lib/utils', () => ({ + encryptSecret: mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }), + })) + + vi.doMock('@/lib/urls/utils', () => ({ + getBaseDomain: vi.fn().mockReturnValue('localhost:3000'), + })) + + vi.doMock('@/lib/environment', () => ({ + isDev: true, + })) + + vi.doMock('@/app/api/chat/utils', () => ({ + checkChatAccess: mockCheckChatAccess, + })) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('GET', () => { + it('should return 401 when user is not authenticated', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue(null), + })) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123') + const { GET } = await import('./route') + const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(401) + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401) + }) + + it('should return 404 when chat not found or access denied', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123') + const { GET } = await import('./route') + const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(404) + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404) + expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id') + }) + + it('should return chat details when user has access', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + const mockChat = { + id: 'chat-123', + subdomain: '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/edit/chat-123') + const { GET } = await import('./route') + const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(200) + expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ + id: 'chat-123', + subdomain: 'test-chat', + title: 'Test Chat', + description: 'A test chat', + customizations: { primaryColor: '#000000' }, + chatUrl: 'http://test-chat.localhost:3000', + hasPassword: true, + }) + }) + }) + + describe('PATCH', () => { + it('should return 401 when user is not authenticated', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue(null), + })) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'PATCH', + body: JSON.stringify({ title: 'Updated Chat' }), + }) + const { PATCH } = await import('./route') + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(401) + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401) + }) + + it('should return 404 when chat not found or access denied', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'PATCH', + body: JSON.stringify({ title: 'Updated Chat' }), + }) + const { PATCH } = await import('./route') + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(404) + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404) + expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id') + }) + + it('should update chat when user has access', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + const mockChat = { + id: 'chat-123', + subdomain: 'test-chat', + title: 'Test Chat', + authType: 'public', + } + + mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'PATCH', + body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }), + }) + const { PATCH } = await import('./route') + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(200) + expect(mockUpdate).toHaveBeenCalled() + expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ + id: 'chat-123', + chatUrl: 'http://test-chat.localhost:3000', + message: 'Chat deployment updated successfully', + }) + }) + + it('should handle subdomain conflicts', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + const mockChat = { + id: 'chat-123', + subdomain: 'test-chat', + title: 'Test Chat', + } + + mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) + // Mock subdomain conflict + mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', subdomain: 'new-subdomain' }]) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'PATCH', + body: JSON.stringify({ subdomain: 'new-subdomain' }), + }) + const { PATCH } = await import('./route') + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(400) + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400) + }) + + it('should validate password requirement for password auth', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + const mockChat = { + id: 'chat-123', + subdomain: 'test-chat', + title: 'Test Chat', + authType: 'public', + password: null, + } + + mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'PATCH', + body: JSON.stringify({ authType: 'password' }), // No password provided + }) + const { PATCH } = await import('./route') + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(400) + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + 'Password is required when using password protection', + 400 + ) + }) + + it('should allow access when user has workspace admin permission', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'admin-user-id' }, + }), + })) + + const mockChat = { + id: 'chat-123', + subdomain: 'test-chat', + title: 'Test Chat', + authType: 'public', + } + + // User doesn't own chat but has workspace admin access + mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'PATCH', + body: JSON.stringify({ title: 'Admin Updated Chat' }), + }) + const { PATCH } = await import('./route') + 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 () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue(null), + })) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'DELETE', + }) + const { DELETE } = await import('./route') + const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(401) + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401) + }) + + it('should return 404 when chat not found or access denied', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + mockCheckChatAccess.mockResolvedValue({ hasAccess: false }) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'DELETE', + }) + const { DELETE } = await import('./route') + const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(404) + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404) + expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id') + }) + + it('should delete chat when user has access', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + mockCheckChatAccess.mockResolvedValue({ hasAccess: true }) + mockWhere.mockResolvedValue(undefined) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'DELETE', + }) + const { DELETE } = await import('./route') + const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(200) + expect(mockDelete).toHaveBeenCalled() + expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ + message: 'Chat deployment deleted successfully', + }) + }) + + it('should allow deletion when user has workspace admin permission', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'admin-user-id' }, + }), + })) + + // User doesn't own chat but has workspace admin access + mockCheckChatAccess.mockResolvedValue({ hasAccess: true }) + mockWhere.mockResolvedValue(undefined) + + const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', { + method: 'DELETE', + }) + const { DELETE } = await import('./route') + 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(mockDelete).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/chat/edit/[id]/route.ts b/apps/sim/app/api/chat/edit/[id]/route.ts index 8e016108f..4e7595dea 100644 --- a/apps/sim/app/api/chat/edit/[id]/route.ts +++ b/apps/sim/app/api/chat/edit/[id]/route.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -6,6 +6,7 @@ import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console-logger' import { getBaseDomain } from '@/lib/urls/utils' import { encryptSecret } from '@/lib/utils' +import { checkChatAccess } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import { db } from '@/db' import { chat } from '@/db/schema' @@ -57,23 +58,19 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ return createErrorResponse('Unauthorized', 401) } - // Get the specific chat deployment - const chatInstance = await db - .select() - .from(chat) - .where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id))) - .limit(1) + // Check if user has access to view this chat + const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id) - if (chatInstance.length === 0) { + if (!hasAccess || !chatRecord) { return createErrorResponse('Chat not found or access denied', 404) } // Create a new result object without the password - const { password, ...safeData } = chatInstance[0] + const { password, ...safeData } = chatRecord - const chatUrl = isDev - ? `http://${chatInstance[0].subdomain}.${getBaseDomain()}` - : `https://${chatInstance[0].subdomain}.simstudio.ai` + const baseDomain = getBaseDomain() + const protocol = isDev ? 'http' : 'https' + const chatUrl = `${protocol}://${chatRecord.subdomain}.${baseDomain}` const result = { ...safeData, @@ -107,17 +104,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< try { const validatedData = chatUpdateSchema.parse(body) - // Verify the chat exists and belongs to the user - const existingChat = await db - .select() - .from(chat) - .where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id))) - .limit(1) + // Check if user has access to edit this chat + const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id) - if (existingChat.length === 0) { + if (!hasAccess || !existingChatRecord) { return createErrorResponse('Chat not found or access denied', 404) } + const existingChat = [existingChatRecord] // Keep array format for compatibility + // Extract validated data const { workflowId, @@ -219,9 +214,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const updatedSubdomain = subdomain || existingChat[0].subdomain - const chatUrl = isDev - ? `http://${updatedSubdomain}.${getBaseDomain()}` - : `https://${updatedSubdomain}.simstudio.ai` + const baseDomain = getBaseDomain() + const protocol = isDev ? 'http' : 'https' + const chatUrl = `${protocol}://${updatedSubdomain}.${baseDomain}` logger.info(`Chat "${chatId}" updated successfully`) @@ -260,14 +255,10 @@ export async function DELETE( return createErrorResponse('Unauthorized', 401) } - // Verify the chat exists and belongs to the user - const existingChat = await db - .select() - .from(chat) - .where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id))) - .limit(1) + // Check if user has access to delete this chat + const { hasAccess } = await checkChatAccess(chatId, session.user.id) - if (existingChat.length === 0) { + if (!hasAccess) { return createErrorResponse('Chat not found or access denied', 404) } diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index f05d521be..3ab6b6b55 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -18,6 +18,7 @@ describe('Chat API Route', () => { const mockCreateSuccessResponse = vi.fn() const mockCreateErrorResponse = vi.fn() const mockEncryptSecret = vi.fn() + const mockCheckWorkflowAccessForChatCreation = vi.fn() beforeEach(() => { vi.resetModules() @@ -71,6 +72,10 @@ describe('Chat API Route', () => { vi.doMock('uuid', () => ({ v4: vi.fn().mockReturnValue('test-uuid'), })) + + vi.doMock('@/app/api/chat/utils', () => ({ + checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, + })) }) afterEach(() => { @@ -194,7 +199,7 @@ describe('Chat API Route', () => { expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400) }) - it('should reject if workflow not found or not owned by user', async () => { + it('should reject if workflow not found', async () => { vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ user: { id: 'user-id' }, @@ -212,7 +217,7 @@ describe('Chat API Route', () => { } mockLimit.mockResolvedValueOnce([]) // Subdomain is available - mockLimit.mockResolvedValueOnce([]) // Workflow not found + mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false }) const req = new NextRequest('http://localhost:3000/api/chat', { method: 'POST', @@ -228,6 +233,158 @@ describe('Chat API Route', () => { ) }) + it('should allow chat deployment when user owns workflow directly', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + vi.doMock('@/lib/env', () => ({ + env: { + NODE_ENV: 'development', + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + }, + })) + + const validData = { + workflowId: 'workflow-123', + subdomain: 'test-chat', + title: 'Test Chat', + customizations: { + primaryColor: '#000000', + welcomeMessage: 'Hello', + }, + } + + mockLimit.mockResolvedValueOnce([]) // Subdomain is available + mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ + hasAccess: true, + workflow: { userId: 'user-id', workspaceId: null, isDeployed: true }, + }) + mockReturning.mockResolvedValue([{ id: 'test-uuid' }]) + + const req = new NextRequest('http://localhost:3000/api/chat', { + method: 'POST', + body: JSON.stringify(validData), + }) + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') + }) + + it('should allow chat deployment when user has workspace admin permission', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + vi.doMock('@/lib/env', () => ({ + env: { + NODE_ENV: 'development', + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + }, + })) + + const validData = { + workflowId: 'workflow-123', + subdomain: 'test-chat', + title: 'Test Chat', + customizations: { + primaryColor: '#000000', + welcomeMessage: 'Hello', + }, + } + + mockLimit.mockResolvedValueOnce([]) // Subdomain is available + mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ + hasAccess: true, + workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true }, + }) + mockReturning.mockResolvedValue([{ id: 'test-uuid' }]) + + const req = new NextRequest('http://localhost:3000/api/chat', { + method: 'POST', + body: JSON.stringify(validData), + }) + const { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(200) + expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') + }) + + it('should reject when workflow is in workspace but user lacks admin permission', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + const validData = { + workflowId: 'workflow-123', + subdomain: 'test-chat', + title: 'Test Chat', + customizations: { + primaryColor: '#000000', + welcomeMessage: 'Hello', + }, + } + + mockLimit.mockResolvedValueOnce([]) // Subdomain is available + mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ + hasAccess: false, + }) + + const req = new NextRequest('http://localhost:3000/api/chat', { + method: 'POST', + body: JSON.stringify(validData), + }) + const { POST } = await import('./route') + 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 () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-id' }, + }), + })) + + const validData = { + workflowId: 'workflow-123', + subdomain: 'test-chat', + title: 'Test Chat', + customizations: { + primaryColor: '#000000', + welcomeMessage: 'Hello', + }, + } + + mockLimit.mockResolvedValueOnce([]) // Subdomain 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 { POST } = await import('./route') + const response = await POST(req) + + expect(response.status).toBe(500) + expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') + }) + it('should reject if workflow is not deployed', async () => { vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -246,7 +403,10 @@ describe('Chat API Route', () => { } mockLimit.mockResolvedValueOnce([]) // Subdomain is available - mockLimit.mockResolvedValueOnce([{ isDeployed: false }]) // Workflow exists but not deployed + mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ + hasAccess: true, + workflow: { userId: 'user-id', workspaceId: null, isDeployed: false }, + }) const req = new NextRequest('http://localhost:3000/api/chat', { method: 'POST', @@ -261,57 +421,5 @@ describe('Chat API Route', () => { 400 ) }) - - it('should successfully create a chat deployment', async () => { - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) - - vi.doMock('@/lib/env', () => ({ - env: { - NODE_ENV: 'development', - NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - }, - })) - - vi.stubGlobal('process', { - ...process, - env: { - ...process.env, - NODE_ENV: 'development', - NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - }, - }) - - const validData = { - workflowId: 'workflow-123', - subdomain: 'test-chat', - title: 'Test Chat', - customizations: { - primaryColor: '#000000', - welcomeMessage: 'Hello', - }, - } - - mockLimit.mockResolvedValueOnce([]) // Subdomain is available - mockLimit.mockResolvedValueOnce([{ isDeployed: true }]) // Workflow exists and is deployed - mockReturning.mockResolvedValue([{ id: 'test-uuid' }]) - - const req = new NextRequest('http://localhost:3000/api/chat', { - method: 'POST', - body: JSON.stringify(validData), - }) - const { POST } = await import('./route') - const response = await POST(req) - - expect(response.status).toBe(200) - expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ - id: 'test-uuid', - chatUrl: 'http://test-chat.localhost:3000', - message: 'Chat deployment created successfully', - }) - }) }) }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index ae55d3b30..65701515d 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' @@ -7,9 +7,10 @@ import { env } from '@/lib/env' import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console-logger' import { encryptSecret } from '@/lib/utils' +import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import { db } from '@/db' -import { chat, workflow } from '@/db/schema' +import { chat } from '@/db/schema' const logger = createLogger('ChatAPI') @@ -107,19 +108,18 @@ export async function POST(request: NextRequest) { return createErrorResponse('Subdomain already in use', 400) } - // Verify the workflow exists and belongs to the user - const workflowExists = await db - .select() - .from(workflow) - .where(and(eq(workflow.id, workflowId), eq(workflow.userId, session.user.id))) - .limit(1) + // Check if user has permission to create chat for this workflow + const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation( + workflowId, + session.user.id + ) - if (workflowExists.length === 0) { + if (!hasAccess || !workflowRecord) { return createErrorResponse('Workflow not found or access denied', 404) } // Verify the workflow is deployed (required for chat deployment) - if (!workflowExists[0].isDeployed) { + if (!workflowRecord.isDeployed) { return createErrorResponse('Workflow must be deployed before creating a chat', 400) } @@ -169,23 +169,24 @@ export async function POST(request: NextRequest) { }) // Return successful response with chat URL - // Check if we're in development or production + // Generate chat URL based on the configured base URL const baseUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' let chatUrl: string - if (isDev) { - try { - const url = new URL(baseUrl) - chatUrl = `${url.protocol}//${subdomain}.${url.host}` - } catch (error) { - logger.warn('Failed to parse baseUrl, falling back to localhost:', { - baseUrl, - error: error instanceof Error ? error.message : 'Unknown error', - }) + try { + const url = new URL(baseUrl) + chatUrl = `${url.protocol}//${subdomain}.${url.host}` + } catch (error) { + logger.warn('Failed to parse baseUrl, falling back to defaults:', { + baseUrl, + error: error instanceof Error ? error.message : 'Unknown error', + }) + // Fallback based on environment + if (isDev) { chatUrl = `http://${subdomain}.localhost:3000` + } else { + chatUrl = `https://${subdomain}.simstudio.ai` } - } else { - chatUrl = `https://${subdomain}.simstudio.ai` } logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index dce8758c0..0e4f25659 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -7,7 +7,6 @@ import type { NextResponse } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { env } from '@/lib/env' -// Mock all the problematic imports that cause timeouts vi.mock('@/db', () => ({ db: { select: vi.fn(), diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index e00b76648..dd1e0e014 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -5,7 +5,9 @@ import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console-logger' import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session' import { buildTraceSpans } from '@/lib/logs/trace-spans' +import { hasAdminPermission } from '@/lib/permissions/utils' import { processStreamingBlockLogs } from '@/lib/tokenization' +import { getEmailDomain } from '@/lib/urls/utils' import { decryptSecret } from '@/lib/utils' import { db } from '@/db' import { chat, environment as envTable, userStats, workflow } from '@/db/schema' @@ -21,6 +23,80 @@ declare global { const logger = createLogger('ChatAuthUtils') +/** + * Check if user has permission to create a chat for a specific workflow + * Either the user owns the workflow directly OR has admin permission for the workflow's workspace + */ +export async function checkWorkflowAccessForChatCreation( + workflowId: string, + userId: string +): Promise<{ hasAccess: boolean; workflow?: any }> { + // Get workflow data + const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1) + + if (workflowData.length === 0) { + return { hasAccess: false } + } + + const workflowRecord = workflowData[0] + + // Case 1: User owns the workflow directly + if (workflowRecord.userId === userId) { + return { hasAccess: true, workflow: workflowRecord } + } + + // Case 2: Workflow belongs to a workspace and user has admin permission + if (workflowRecord.workspaceId) { + const hasAdmin = await hasAdminPermission(userId, workflowRecord.workspaceId) + if (hasAdmin) { + return { hasAccess: true, workflow: workflowRecord } + } + } + + return { hasAccess: false } +} + +/** + * Check if user has access to view/edit/delete a specific chat + * Either the user owns the chat directly OR has admin permission for the workflow's workspace + */ +export async function checkChatAccess( + chatId: string, + userId: string +): Promise<{ hasAccess: boolean; chat?: any }> { + // Get chat with workflow information + const chatData = await db + .select({ + chat: chat, + workflowWorkspaceId: workflow.workspaceId, + }) + .from(chat) + .innerJoin(workflow, eq(chat.workflowId, workflow.id)) + .where(eq(chat.id, chatId)) + .limit(1) + + if (chatData.length === 0) { + return { hasAccess: false } + } + + const { chat: chatRecord, workflowWorkspaceId } = chatData[0] + + // Case 1: User owns the chat directly + if (chatRecord.userId === userId) { + return { hasAccess: true, chat: chatRecord } + } + + // Case 2: Chat's workflow belongs to a workspace and user has admin permission + if (workflowWorkspaceId) { + const hasAdmin = await hasAdminPermission(userId, workflowWorkspaceId) + if (hasAdmin) { + return { hasAccess: true, chat: chatRecord } + } + } + + return { hasAccess: false } +} + export const encryptAuthToken = (subdomainId: string, type: string): string => { return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64') } @@ -66,7 +142,7 @@ export const setChatAuthCookie = ( sameSite: 'lax', path: '/', // Using subdomain for the domain in production - domain: isDev ? undefined : '.simstudio.ai', + domain: isDev ? undefined : `.${getEmailDomain()}`, maxAge: 60 * 60 * 24, // 24 hours }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index 9af049447..a5f6507f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -30,7 +30,6 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' -import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console-logger' import { getBaseDomain } from '@/lib/urls/utils' import { cn } from '@/lib/utils' @@ -54,11 +53,10 @@ interface ChatDeployProps { type AuthType = 'public' | 'password' | 'email' const getDomainSuffix = (() => { - const suffix = isDev ? `.${getBaseDomain()}` : '.simstudio.ai' + const suffix = `.${getBaseDomain()}` return () => suffix })() -// Define Zod schema for API request validation const chatSchema = z.object({ workflowId: z.string().min(1, 'Workflow ID is required'), subdomain: z @@ -124,10 +122,6 @@ export function ChatDeploy({ selectedOutputIds: string[] } | null>(null) - // State to track if any changes have been made - const [hasChanges, setHasChanges] = useState(false) - - // Confirmation dialogs const [showEditConfirmation, setShowEditConfirmation] = useState(false) const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false) @@ -184,53 +178,6 @@ export function ChatDeploy({ } }, [workflowId]) - // Check for changes when form values update - useEffect(() => { - if (originalValues && existingChat) { - const currentAuthTypeChanged = authType !== originalValues.authType - const subdomainChanged = subdomain !== originalValues.subdomain - const titleChanged = title !== originalValues.title - const descriptionChanged = description !== originalValues.description - const outputBlockChanged = selectedOutputBlocks.some( - (blockId) => !originalValues.selectedOutputIds.includes(blockId) - ) - const welcomeMessageChanged = - welcomeMessage !== - (existingChat.customizations?.welcomeMessage || 'Hi there! How can I help you today?') - - // Check if emails have changed - const emailsChanged = - emails.length !== originalValues.emails.length || - emails.some((email) => !originalValues.emails.includes(email)) - - // Check if password has changed - any value in password field means change - const passwordChanged = password.length > 0 - - // Determine if any changes have been made - const changed = - subdomainChanged || - titleChanged || - descriptionChanged || - currentAuthTypeChanged || - emailsChanged || - passwordChanged || - outputBlockChanged || - welcomeMessageChanged - - setHasChanges(changed) - } - }, [ - subdomain, - title, - description, - authType, - emails, - password, - selectedOutputBlocks, - welcomeMessage, - originalValues, - ]) - // Fetch existing chat data for this workflow const fetchExistingChat = async () => { try { @@ -310,7 +257,6 @@ export function ChatDeploy({ } finally { setIsLoading(false) setDataFetched(true) - setHasChanges(false) // Reset changes detection after loading } } @@ -490,6 +436,8 @@ export function ChatDeploy({ (!originalValues || subdomain !== originalValues.subdomain) ) { setIsCheckingSubdomain(true) + setSubdomainError('') + try { const response = await fetch( `/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}` @@ -497,11 +445,15 @@ export function ChatDeploy({ const data = await response.json() if (!response.ok || !data.available) { - setSubdomainError('This subdomain is already in use') + const errorMsg = data.error || 'This subdomain is already in use' + setSubdomainError(errorMsg) setChatSubmitting(false) setIsCheckingSubdomain(false) + logger.warn('Subdomain validation failed:', errorMsg) return } + + setSubdomainError('') } catch (error) { logger.error('Error checking subdomain availability:', error) setSubdomainError('Error checking subdomain availability') @@ -512,15 +464,16 @@ export function ChatDeploy({ setIsCheckingSubdomain(false) } - // Verify output selection if it's set - if (selectedOutputBlocks.length === 0) { - logger.error('No output blocks selected') - setErrorMessage('Please select at least one output block') + if (subdomainError) { + logger.warn('Blocking submission due to subdomain error:', subdomainError) setChatSubmitting(false) return } - if (subdomainError) { + // Verify output selection if it's set + if (selectedOutputBlocks.length === 0) { + logger.error('No output blocks selected') + setErrorMessage('Please select at least one output block') setChatSubmitting(false) return } @@ -722,6 +675,11 @@ export function ChatDeploy({ const result = await response.json() if (!response.ok) { + if (result.error === 'Subdomain already in use') { + setSubdomainError(result.error) + setChatSubmitting(false) + return + } throw new Error(result.error || `Failed to ${existingChat ? 'update' : 'deploy'} chat`) } @@ -743,7 +701,14 @@ export function ChatDeploy({ } } catch (error: any) { logger.error(`Failed to ${existingChat ? 'update' : 'deploy'} chat:`, error) - setErrorMessage(error.message || 'An unexpected error occurred') + + const errorMessage = error.message || 'An unexpected error occurred' + if (errorMessage.includes('Subdomain already in use') || errorMessage.includes('subdomain')) { + setSubdomainError(errorMessage) + } else { + setErrorMessage(errorMessage) + } + logger.error(`Failed to deploy chat: ${error.message}`, workflowId) } finally { setChatSubmitting(false) @@ -751,26 +716,6 @@ export function ChatDeploy({ } } - // Determine button label based on state - const _getSubmitButtonLabel = () => { - return existingChat ? 'Update Chat' : 'Deploy Chat' - } - - // Check if form submission is possible - const _isFormSubmitDisabled = () => { - return ( - chatSubmitting || - isDeleting || - !subdomain || - !title || - !!subdomainError || - isCheckingSubdomain || - (authType === 'password' && !password && !existingChat) || - (authType === 'email' && emails.length === 0) || - (existingChat && !hasChanges) - ) - } - if (isLoading) { return (
All users will lose access immediately, and this action cannot be undone.