Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions apps/sim/app/api/chat/manage/[id]/password/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* @vitest-environment node
*/
import {
auditMock,
auditMockFns,
authMockFns,
encryptionMock,
encryptionMockFns,
workflowsApiUtilsMock,
workflowsApiUtilsMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckChatAccess } = vi.hoisted(() => ({
mockCheckChatAccess: vi.fn(),
}))

const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
const mockRecordAudit = auditMockFns.mockRecordAudit

vi.mock('@sim/audit', () => auditMock)
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
vi.mock('@/app/api/chat/utils', () => ({
checkChatAccess: mockCheckChatAccess,
}))

import { GET } from '@/app/api/chat/manage/[id]/password/route'

const passwordChat = {
id: 'chat-123',
workflowId: 'workflow-123',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'password',
password: 'encrypted-password',
}

function makeRequest() {
return new NextRequest('http://localhost:3000/api/chat/manage/chat-123/password')
}

function callGet() {
return GET(makeRequest(), { params: Promise.resolve({ id: 'chat-123' }) })
}

describe('Chat Password Reveal API Route', () => {
beforeEach(() => {
vi.clearAllMocks()

authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id', name: 'Test User', email: 'user@example.com' },
})

mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
})

mockDecryptSecret.mockResolvedValue({ decrypted: 'super-secret' })
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: passwordChat,
workspaceId: 'workspace-123',
})
})

it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)

const response = await callGet()

expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
expect(mockDecryptSecret).not.toHaveBeenCalled()
})

it('should return 404 when chat not found or access denied', async () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })

const response = await callGet()

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')
expect(mockDecryptSecret).not.toHaveBeenCalled()
})

it('should return 404 when the chat has no password set', async () => {
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { ...passwordChat, authType: 'public', password: null },
workspaceId: 'workspace-123',
})

const response = await callGet()

expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('This chat does not have a password set')
expect(mockDecryptSecret).not.toHaveBeenCalled()
})

it('should return the decrypted password and record an audit event', async () => {
const response = await callGet()

expect(response.status).toBe(200)
const data = await response.json()
expect(data.password).toBe('super-secret')
expect(mockDecryptSecret).toHaveBeenCalledWith('encrypted-password')
expect(mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'workspace-123',
actorId: 'user-id',
action: 'chat.password_viewed',
resourceId: 'chat-123',
})
)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})

it('should return 500 when decryption fails', async () => {
mockDecryptSecret.mockRejectedValue(new Error('Decryption failed'))

const response = await callGet()

expect(response.status).toBe(500)
const data = await response.json()
expect(data.error).toBe('Decryption failed')
expect(mockRecordAudit).not.toHaveBeenCalled()
})
})
77 changes: 77 additions & 0 deletions apps/sim/app/api/chat/manage/[id]/password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getChatPasswordContract } from '@/lib/api/contracts/chats'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { decryptSecret } from '@/lib/core/security/encryption'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse } from '@/app/api/workflows/utils'

export const dynamic = 'force-dynamic'

const logger = createLogger('ChatPasswordAPI')
const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const

/**
* GET endpoint that reveals a chat deployment's current password.
* Restricted to workspace admins (checkChatAccess requires admin permission
* on the workflow's workspace); each reveal is recorded in the audit log.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
try {
const session = await getSession()

if (!session) {
return createErrorResponse('Unauthorized', 401)
}

const parsed = await parseRequest(getChatPasswordContract, request, context)
if (!parsed.success) return parsed.response

const { id: chatId } = parsed.data.params

const {
hasAccess,
chat: chatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)

if (!hasAccess || !chatRecord) {
return createErrorResponse('Chat not found or access denied', 404)
}

if (chatRecord.authType !== 'password' || !chatRecord.password) {
return createErrorResponse('This chat does not have a password set', 404)
}

const { decrypted } = await decryptSecret(chatRecord.password)

recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_PASSWORD_VIEWED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord.title,
description: `Viewed the password for chat deployment "${chatRecord.title}"`,
metadata: {
identifier: chatRecord.identifier,
workflowId: chatRecord.workflowId,
},
request,
})

return NextResponse.json({ password: decrypted }, { headers: PRIVATE_NO_STORE })
} catch (error) {
logger.error('Error revealing chat password:', error)
return createErrorResponse(getErrorMessage(error, 'Failed to reveal chat password'), 500)
}
}
)
18 changes: 18 additions & 0 deletions apps/sim/app/api/chat/manage/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,24 @@ describe('Chat Edit API Route', () => {
expect(data.error).toBe('Password is required when using password protection')
})

it('rejects a whitespace-only replacement password', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
})

const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ authType: 'password', 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 cannot contain only whitespace')
expect(mockCheckChatAccess).not.toHaveBeenCalled()
expect(mockEncryptSecret).not.toHaveBeenCalled()
})

it('should keep the existing password when updating a password-protected chat', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-id' },
Expand Down
Loading
Loading