Files
simstudioai--sim/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts
T
wehub-resource-sync d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

438 lines
15 KiB
TypeScript

/**
* @vitest-environment node
*/
import { auditMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionContext } from '@/lib/copilot/request/types'
const {
ensureWorkflowAccessMock,
getWorkspaceWithOwnerMock,
isFeatureEnabledMock,
isOrganizationOnEnterprisePlanMock,
publishCustomBlockMock,
updateCustomBlockMock,
deleteCustomBlockMock,
getCustomBlockWithInputsByWorkflowIdMock,
listWorkspaceFilesMock,
fetchWorkspaceFileBufferMock,
uploadFileMock,
} = vi.hoisted(() => ({
ensureWorkflowAccessMock: vi.fn(),
getWorkspaceWithOwnerMock: vi.fn(),
isFeatureEnabledMock: vi.fn(),
isOrganizationOnEnterprisePlanMock: vi.fn(),
publishCustomBlockMock: vi.fn(),
updateCustomBlockMock: vi.fn(),
deleteCustomBlockMock: vi.fn(),
getCustomBlockWithInputsByWorkflowIdMock: vi.fn(),
listWorkspaceFilesMock: vi.fn(),
fetchWorkspaceFileBufferMock: vi.fn(),
uploadFileMock: vi.fn(),
}))
vi.mock('@sim/audit', () => auditMock)
vi.mock('../access', () => ({
ensureWorkflowAccess: ensureWorkflowAccessMock,
ensureWorkspaceAccess: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getWorkspaceWithOwner: getWorkspaceWithOwnerMock,
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
isFeatureEnabled: isFeatureEnabledMock,
}))
vi.mock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock,
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
listWorkspaceFiles: listWorkspaceFilesMock,
fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock,
}))
vi.mock('@/lib/uploads/core/storage-service', () => ({
uploadFile: uploadFileMock,
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
isImageFileType: (type: string) => type.startsWith('image/'),
}))
vi.mock('@/lib/workflows/custom-blocks/operations', () => {
class CustomBlockValidationError extends Error {}
return {
CustomBlockValidationError,
publishCustomBlock: publishCustomBlockMock,
updateCustomBlock: updateCustomBlockMock,
deleteCustomBlock: deleteCustomBlockMock,
getCustomBlockWithInputsByWorkflowId: getCustomBlockWithInputsByWorkflowIdMock,
}
})
import { executeDeployCustomBlock } from './custom-block'
const context = { userId: 'user-1', workflowId: 'wf-1' } as ExecutionContext
const publishedBlock = {
id: 'cb-1',
organizationId: 'org-1',
workflowId: 'wf-1',
workflowName: 'Test Workflow',
workspaceId: 'ws-1',
workspaceName: 'Workspace',
type: 'custom_block_abc123',
name: 'Enrich Lead',
description: 'Enrich a lead by email',
iconUrl: null,
enabled: true,
inputFields: [{ id: 'f1', name: 'email', type: 'string' }],
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }],
}
describe('executeDeployCustomBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
ensureWorkflowAccessMock.mockResolvedValue({
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: true },
})
getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: 'org-1' })
isFeatureEnabledMock.mockResolvedValue(true)
isOrganizationOnEnterprisePlanMock.mockResolvedValue(true)
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(null)
})
it('publishes a new custom block', async () => {
publishCustomBlockMock.mockResolvedValue(publishedBlock)
const result = await executeDeployCustomBlock(
{
name: 'Enrich Lead',
description: 'Enrich a lead by email',
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }],
},
context
)
expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin')
expect(publishCustomBlockMock).toHaveBeenCalledWith({
organizationId: 'org-1',
workspaceId: 'ws-1',
workflowId: 'wf-1',
userId: 'user-1',
name: 'Enrich Lead',
description: 'Enrich a lead by email',
iconUrl: undefined,
inputs: undefined,
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }],
})
expect(result.success).toBe(true)
expect(result.output).toMatchObject({
workflowId: 'wf-1',
blockType: 'custom_block_abc123',
isDeployed: true,
updated: false,
deploymentType: 'custom_block',
deploymentStatus: { customBlock: { isDeployed: true, name: 'Enrich Lead' } },
})
})
it('returns a clean admin-permission error when workflow access is denied', async () => {
ensureWorkflowAccessMock.mockRejectedValue(new Error('Unauthorized workflow access'))
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('admin permission')
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
it('surfaces workflow-not-found from access resolution', async () => {
ensureWorkflowAccessMock.mockRejectedValue(new Error('Workflow wf-1 not found'))
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('not found')
})
it('requires a name on first publish', async () => {
const result = await executeDeployCustomBlock({}, context)
expect(result.success).toBe(false)
expect(result.error).toContain('name is required')
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
it('requires the workflow to be deployed on first publish', async () => {
ensureWorkflowAccessMock.mockResolvedValue({
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: false },
})
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('deploy_api')
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
it('updates an existing block in place', async () => {
getCustomBlockWithInputsByWorkflowIdMock
.mockResolvedValueOnce(publishedBlock)
.mockResolvedValueOnce({ ...publishedBlock, name: 'Enrich Lead v2' })
const result = await executeDeployCustomBlock({ name: 'Enrich Lead v2' }, context)
expect(updateCustomBlockMock).toHaveBeenCalledWith('cb-1', {
name: 'Enrich Lead v2',
description: undefined,
iconUrl: undefined,
inputs: undefined,
exposedOutputs: undefined,
})
expect(publishCustomBlockMock).not.toHaveBeenCalled()
expect(result.success).toBe(true)
expect(result.output).toMatchObject({ updated: true, name: 'Enrich Lead v2' })
})
it('unpublishes the block on undeploy', async () => {
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock)
const result = await executeDeployCustomBlock({ action: 'undeploy' }, context)
expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1')
expect(result.success).toBe(true)
expect(result.output).toMatchObject({
isDeployed: false,
removed: true,
action: 'undeploy',
blockType: 'custom_block_abc123',
})
})
it('updates an existing block without requiring the enterprise plan', async () => {
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
getCustomBlockWithInputsByWorkflowIdMock
.mockResolvedValueOnce(publishedBlock)
.mockResolvedValueOnce(publishedBlock)
const result = await executeDeployCustomBlock({ description: 'refreshed copy' }, context)
expect(result.success).toBe(true)
expect(updateCustomBlockMock).toHaveBeenCalled()
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
it('undeploys without requiring the enterprise plan', async () => {
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock)
const result = await executeDeployCustomBlock({ action: 'undeploy' }, context)
expect(result.success).toBe(true)
expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1')
})
it('does not clear the stored name when a republish sends whitespace', async () => {
getCustomBlockWithInputsByWorkflowIdMock
.mockResolvedValueOnce(publishedBlock)
.mockResolvedValueOnce(publishedBlock)
const result = await executeDeployCustomBlock({ name: ' ' }, context)
expect(updateCustomBlockMock).toHaveBeenCalledWith(
'cb-1',
expect.objectContaining({ name: undefined })
)
expect(result.success).toBe(true)
})
it('rejects oversized exposedOutputs and inputs arrays', async () => {
const outputs = Array.from({ length: 51 }, (_, i) => ({
blockId: `b${i}`,
path: 'content',
name: `out${i}`,
}))
const tooManyOutputs = await executeDeployCustomBlock(
{ name: 'Enrich Lead', exposedOutputs: outputs },
context
)
expect(tooManyOutputs.success).toBe(false)
expect(tooManyOutputs.error).toContain('50')
const inputs = Array.from({ length: 51 }, (_, i) => ({ id: `f${i}` }))
const tooManyInputs = await executeDeployCustomBlock({ name: 'Enrich Lead', inputs }, context)
expect(tooManyInputs.success).toBe(false)
expect(tooManyInputs.error).toContain('50')
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
it('rejects oversized per-item fields', async () => {
const longPlaceholder = await executeDeployCustomBlock(
{ name: 'Enrich Lead', inputs: [{ id: 'f1', placeholder: 'x'.repeat(201) }] },
context
)
expect(longPlaceholder.success).toBe(false)
expect(longPlaceholder.error).toContain('200')
const longOutputName = await executeDeployCustomBlock(
{
name: 'Enrich Lead',
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'x'.repeat(61) }],
},
context
)
expect(longOutputName.success).toBe(false)
expect(longOutputName.error).toContain('60')
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
it('rejects exposedOutputs entries missing required fields', async () => {
const result = await executeDeployCustomBlock(
{ name: 'Enrich Lead', exposedOutputs: [{ blockId: 'b1', path: '', name: 'out' }] },
context
)
expect(result.success).toBe(false)
expect(result.error).toContain('blockId, path, and name')
})
it('fails undeploy when the workflow is not published as a block', async () => {
const result = await executeDeployCustomBlock({ action: 'undeploy' }, context)
expect(result.success).toBe(false)
expect(deleteCustomBlockMock).not.toHaveBeenCalled()
})
it('fails when the feature flag is off', async () => {
isFeatureEnabledMock.mockResolvedValue(false)
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('not enabled')
})
it('fails when the org is not on the enterprise plan', async () => {
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('enterprise')
})
it('ingests a workspace-file icon into public icon storage', async () => {
listWorkspaceFilesMock.mockResolvedValue([
{
name: 'icon.png',
folderPath: null,
type: 'image/png',
size: 1024,
key: 'workspace/ws-1/123-abc-icon.png',
},
])
fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes'))
uploadFileMock.mockResolvedValue({ path: '/api/files/serve/s3/workspace-logos%2Ficon.png' })
publishCustomBlockMock.mockResolvedValue(publishedBlock)
const result = await executeDeployCustomBlock(
{ name: 'Enrich Lead', iconUrl: 'files/icon.png' },
context
)
expect(uploadFileMock).toHaveBeenCalledWith(
expect.objectContaining({
context: 'workspace-logos',
contentType: 'image/png',
customKey: expect.stringMatching(/^workspace-logos\/\d+-[A-Za-z0-9_-]+-icon\.png$/),
preserveKey: true,
metadata: { workspaceId: 'ws-1', userId: 'user-1', originalName: 'icon.png' },
})
)
expect(publishCustomBlockMock).toHaveBeenCalledWith(
expect.objectContaining({ iconUrl: '/api/files/serve/s3/workspace-logos%2Ficon.png' })
)
expect(result.success).toBe(true)
})
it('passes an external icon URL through without ingestion', async () => {
publishCustomBlockMock.mockResolvedValue(publishedBlock)
const result = await executeDeployCustomBlock(
{ name: 'Enrich Lead', iconUrl: 'https://example.com/icon.png' },
context
)
expect(uploadFileMock).not.toHaveBeenCalled()
expect(publishCustomBlockMock).toHaveBeenCalledWith(
expect.objectContaining({ iconUrl: 'https://example.com/icon.png' })
)
expect(result.success).toBe(true)
})
it('fails when the icon workspace file does not exist', async () => {
listWorkspaceFilesMock.mockResolvedValue([])
const result = await executeDeployCustomBlock(
{ name: 'Enrich Lead', iconUrl: 'files/missing.png' },
context
)
expect(result.success).toBe(false)
expect(result.error).toContain('not found')
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
it('rejects non-https icon URL schemes on pass-through', async () => {
const dataUri = await executeDeployCustomBlock(
{ name: 'Enrich Lead', iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+' },
context
)
expect(dataUri.success).toBe(false)
expect(dataUri.error).toContain('https')
const plainHttp = await executeDeployCustomBlock(
{ name: 'Enrich Lead', iconUrl: 'http://example.com/icon.png' },
context
)
expect(plainHttp.success).toBe(false)
expect(publishCustomBlockMock).not.toHaveBeenCalled()
publishCustomBlockMock.mockResolvedValue(publishedBlock)
const servePath = await executeDeployCustomBlock(
{ name: 'Enrich Lead', iconUrl: '/api/files/serve/workspace-logos%2Ficon.png' },
context
)
expect(servePath.success).toBe(true)
})
it('fails when the icon workspace file is not an image', async () => {
listWorkspaceFilesMock.mockResolvedValue([
{ name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' },
])
const result = await executeDeployCustomBlock(
{ name: 'Enrich Lead', iconUrl: 'files/notes.pdf' },
context
)
expect(result.success).toBe(false)
expect(result.error).toContain('image')
expect(uploadFileMock).not.toHaveBeenCalled()
})
it('fails when the workspace has no organization', async () => {
getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: null })
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('organization')
})
})