d25d482dc2
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
556 lines
16 KiB
TypeScript
556 lines
16 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|