6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
/**
|
|
* Tests for chat API utils
|
|
*
|
|
* @vitest-environment node
|
|
*/
|
|
import {
|
|
authMockFns,
|
|
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,
|
|
mockCheckRateLimitDirect,
|
|
} = vi.hoisted(() => ({
|
|
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
|
|
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
|
|
mockValidateAuthToken: vi.fn().mockReturnValue(false),
|
|
mockSetDeploymentAuthCookie: vi.fn(),
|
|
mockIsEmailAllowed: vi.fn(),
|
|
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
|
|
}))
|
|
|
|
vi.mock('@/lib/core/rate-limiter', () => ({
|
|
RateLimiter: class {
|
|
checkRateLimitDirect = mockCheckRateLimitDirect
|
|
},
|
|
}))
|
|
|
|
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/workflows/utils', () => workflowsUtilsMock)
|
|
|
|
import { decryptSecret } from '@/lib/core/security/encryption'
|
|
import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
|
|
|
|
const mockGetSession = authMockFns.mockGetSession
|
|
|
|
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)
|
|
})
|
|
})
|
|
})
|