Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { createRouteContext } from '@sim/testing/helpers/http'
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
import {
workspaceAuthorizationMock,
workspaceAuthorizationMockFns,
} from '@sim/testing/mocks/workspace-authorization.mock'
import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock'
import {
workspaceForkingLineageMock,
workspaceForkingLineageMockFns,
} from '@sim/testing/mocks/workspace-forking-lineage.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'

const mocks = vi.hoisted(() => ({
loadSourceDeployedWorkflow: vi.fn(),
loadTargetDraftState: vi.fn(),
loadForkBlockMap: vi.fn(),
resolveForkPlanItem: vi.fn(),
}))

vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock)
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock)
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
loadSourceDeployedWorkflow: mocks.loadSourceDeployedWorkflow,
loadTargetDraftState: mocks.loadTargetDraftState,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
loadForkBlockMap: mocks.loadForkBlockMap,
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({
resolveForkPlanItem: mocks.resolveForkPlanItem,
}))

import { GET } from '@/app/api/workspaces/[id]/fork/workflow-diff/route'

const { mockAuthorizeWorkspaceOperation } = workspaceAuthorizationMockFns
const mockGetSession = authMockFns.mockGetSession
permissionsMockFns.mockGetWorkspaceWithOwner.mockImplementation(async (id: string) => ({
id,
name: id,
organizationId: null,
allowPersonalApiKeys: true,
}))

const WORKSPACE_ID = 'child'
const routeContext = createRouteContext({ id: WORKSPACE_ID })
const BASE_URL = `http://localhost/api/workspaces/${WORKSPACE_ID}/fork/workflow-diff`

const emptyState = { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }

function request(query: Record<string, string>) {
const url = `${BASE_URL}?${new URLSearchParams(query).toString()}`
return createMockRequest('GET', undefined, undefined, url)
}

describe('fork workflow-diff route', () => {
beforeEach(() => {
mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } })
mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined)
workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue({
parentWorkspaceId: 'parent',
childWorkspaceId: 'child',
})
mocks.loadForkBlockMap.mockResolvedValue({ parentToChild: new Map(), childToParent: new Map() })
mocks.loadSourceDeployedWorkflow.mockImplementation(async (_ws: string, id: string) =>
id === 'wf-src' ? { summary: { id, name: 'Support Agent' }, state: emptyState } : null
)
mocks.resolveForkPlanItem.mockResolvedValue({
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: null,
mode: 'create',
sourceMeta: { name: 'Support Agent' },
})
})

it('does not read any state when workspace authorization is refused', async () => {
mockAuthorizeWorkspaceOperation.mockRejectedValue(
new OrchestrationError('forbidden', 'Admin access required')
)

const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(403)
})

it('rejects a request without the source workflow id', async () => {
const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push' }),
routeContext
)

expect(response.status).toBe(400)
})

it('rejects workspaces that are not a direct fork edge without reading state', async () => {
workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(null)

const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(400)
})

it('maps a workflow outside the sync plan to 404', async () => {
const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'foreign' }),
routeContext
)

expect(response.status).toBe(404)
})

it('returns the before and after states with their labels', async () => {
const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
targetWorkflowId: null,
before: null,
after: emptyState,
beforeLabel: 'Support Agent (current)',
afterLabel: 'Support Agent (deployed)',
})
})
it('maps a workflow whose target is excluded from sync to 404', async () => {
mocks.resolveForkPlanItem.mockResolvedValue(null)

const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(404)
})

it('returns the target draft as before when the sync replaces a workflow', async () => {
const targetState = {
...emptyState,
blocks: {},
}
mocks.resolveForkPlanItem.mockResolvedValue({
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Support Agent prod',
mode: 'replace',
sourceMeta: { name: 'Support Agent' },
})
/* Only the replaced target in the target workspace has a draft; any other read is a 404. */
mocks.loadTargetDraftState.mockImplementation(async (id: string, workspaceId: string) =>
id === 'wf-tgt' && workspaceId === 'parent' ? targetState : null
)

const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
targetWorkflowId: 'wf-tgt',
before: targetState,
beforeLabel: 'Support Agent prod (current)',
})
})
})
20 changes: 20 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getForkWorkflowDiffContract } from '@/lib/api/contracts/workspace-fork'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies'
import { forkOperations } from '@/ee/workspace-forking/application/operations'
import { getWorkspaceSyncWorkflowDiff } from '@/ee/workspace-forking/application/sync-workflow-diff'

export const GET = defineInternalJsonRoute({
contract: getForkWorkflowDiffContract,
auth: internalSessionAuth,
operation: forkOperations.syncPreview,
rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-workflow-diff' }),
errorPolicy: internalForkErrorPolicy,
mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }),
useCase: getWorkspaceSyncWorkflowDiff,
present: (result) => result,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { resolveComparePair } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/compare-pair'

describe('resolveComparePair', () => {
it('puts the older version on the base side whichever was clicked', () => {
const expected = {
base: { kind: 'version', version: 3 },
target: { kind: 'version', version: 5 },
}
expect(resolveComparePair(5, 3)).toEqual(expected)
expect(resolveComparePair(3, 5)).toEqual(expected)
})

it('compares the live version, or any version with nothing live, against the draft', () => {
expect(resolveComparePair(3, 3)).toEqual({
base: { kind: 'version', version: 3 },
target: { kind: 'draft' },
})
expect(resolveComparePair(2, null)).toEqual({
base: { kind: 'version', version: 2 },
target: { kind: 'draft' },
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { CompareSide } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal'

export interface ComparePair {
base: CompareSide
target: CompareSide
}

/**
* Which two sides a "Compare" on a version opens: the older version on the
* left and the newer on the right, except that the live version (or any
* version when nothing is live) compares against the draft, since that is
* what a redeploy would ship.
*/
export function resolveComparePair(version: number, activeVersion: number | null): ComparePair {
if (activeVersion === null || activeVersion === version) {
return { base: { kind: 'version', version }, target: { kind: 'draft' } }
}
const [low, high] = version < activeVersion ? [version, activeVersion] : [activeVersion, version]
return { base: { kind: 'version', version: low }, target: { kind: 'version', version: high } }
}
Loading
Loading