Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
141 changes: 141 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,141 @@
/**
* @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 without echoing the decryption error', async () => {
mockDecryptSecret.mockRejectedValue(
new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"')
)

const response = await callGet()

expect(response.status).toBe(500)
const data = await response.json()
expect(data.error).toBe('Failed to reveal chat password')
expect(mockRecordAudit).not.toHaveBeenCalled()
})
})
81 changes: 81 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,81 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
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)
/**
* Deliberately opaque: the only errors that reach here come from
* decryption, whose messages describe the stored ciphertext's shape.
* The logged error carries the detail for operators.
*/
return createErrorResponse('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
8 changes: 7 additions & 1 deletion apps/sim/app/api/chat/manage/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,13 @@ export const PATCH = withRouteHandler(
}
}

if (encryptedPassword) {
/**
* Only store a new password when the chat ends up password-protected.
* Applying it unconditionally re-armed the secret that the branch above
* just cleared, so `PATCH { authType: 'email', password }` persisted an
* encrypted password on an email-gated chat.
*/
if (encryptedPassword && (authType ?? existingChat[0].authType) === 'password') {
updateData.password = encryptedPassword
}

Expand Down
Loading
Loading