Files
WeHub Mirror 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
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

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)
})
})
})