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
438 lines
15 KiB
TypeScript
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')
|
|
})
|
|
})
|