Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions apps/sim/app/api/workflows/[id]/deployed/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const DEPLOYED_STATE = {
loops: {},
parallels: {},
variables: {},
deploymentVersionId: 'deployment-version-1',
}

const SESSION = {
Expand Down
28 changes: 17 additions & 11 deletions apps/sim/app/api/workflows/[id]/deployed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,23 @@ export const GET = defineInternalJsonRoute({
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ workflowId: params.id, state: 'deployed' as const }),
useCase: readWorkflowDefinition,
present: ({ state }) => ({
deployedState: state
? deployedWorkflowStateSchema.parse({
blocks: state.blocks,
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
variables: 'variables' in state ? (state.variables ?? {}) : {},
})
: null,
}),
present: ({ state }) => {
if (state && (!('deploymentVersionId' in state) || !state.deploymentVersionId)) {
throw new Error('Deployed workflow state is missing its deployment version')
}
return {
deployedState: state
? deployedWorkflowStateSchema.parse({
blocks: state.blocks,
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
variables: 'variables' in state ? (state.variables ?? {}) : {},
deploymentVersionId: state.deploymentVersionId,
})
: null,
}
},
onSuccess: ({ input, result }) => {
if (!result.state) logger.warn('Workflow has no active deployed state', input)
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* @vitest-environment node
*/

import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
read: vi.fn(),
update: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))

vi.mock('@/lib/credential-groups/application/manage-access', () => ({
readCredentialGroupAccess: {
operation: { id: 'credential_groups.access.read' },
execute: mocks.read,
},
updateCredentialGroupAccess: {
operation: { id: 'credential_groups.access.update' },
execute: mocks.update,
},
}))

import { GET, PUT } from '@/app/api/workspaces/[id]/credential-groups/[groupId]/access/route'

const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
const GROUP_ID = 'group-1'
const url = `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/access`
const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) }
const workflows = [
{ id: 'workflow-1', name: 'Support workflow' },
{ id: 'workflow-2', name: 'Finance workflow' },
]

describe('Credential Group access route', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSession.mockResolvedValue({
user: { id: 'admin-1' },
session: { id: 'session-1' },
})
mocks.read.mockResolvedValue({
revision: 1,
allowedWorkflowIds: ['workflow-1'],
workflows,
})
mocks.update.mockResolvedValue({
revision: 2,
allowedWorkflowIds: ['workflow-1', 'workflow-2'],
})
})

it('reads the workflow-only access selection and bounded catalog', async () => {
const request = new NextRequest(url)
const response = await GET(request, context)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({
revision: 1,
allowedWorkflowIds: ['workflow-1'],
workflows,
})
expect(mocks.read).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
input: { assertedWorkspaceId: WORKSPACE_ID, credentialGroupId: GROUP_ID },
request,
})
})

it('updates selected workflows with optimistic revision input', async () => {
const body = {
expectedRevision: 1,
allowedWorkflowIds: ['workflow-2', 'workflow-1'],
}
const request = new NextRequest(url, {
method: 'PUT',
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
})
const response = await PUT(request, context)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({
revision: 2,
allowedWorkflowIds: ['workflow-1', 'workflow-2'],
})
expect(mocks.update).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
input: {
assertedWorkspaceId: WORKSPACE_ID,
credentialGroupId: GROUP_ID,
...body,
},
request,
})
})

it('authenticates before parsing a malformed body', async () => {
mocks.getSession.mockResolvedValue(null)
const request = new NextRequest(url, {
method: 'PUT',
body: '{',
headers: { 'content-type': 'application/json' },
})

const response = await PUT(request, context)

expect(response.status).toBe(401)
expect(mocks.update).not.toHaveBeenCalled()
})

it('rejects the removed raw resource-policy document wire shape', async () => {
const request = new NextRequest(url, {
method: 'PUT',
body: JSON.stringify({
expectedRevision: 1,
allowedWorkflowIds: [],
document: {
version: 1,
resource: { type: 'credential_group', id: GROUP_ID },
statements: [],
},
}),
headers: { 'content-type': 'application/json' },
})

const response = await PUT(request, context)

expect(response.status).toBe(400)
expect(mocks.update).not.toHaveBeenCalled()
})

it('rejects duplicate workflow selections at the HTTP boundary', async () => {
const request = new NextRequest(url, {
method: 'PUT',
body: JSON.stringify({
expectedRevision: 1,
allowedWorkflowIds: ['workflow-1', 'workflow-1'],
}),
headers: { 'content-type': 'application/json' },
})

const response = await PUT(request, context)

expect(response.status).toBe(400)
expect(mocks.update).not.toHaveBeenCalled()
})

it('rejects an oversized access payload before parsing it', async () => {
const body = JSON.stringify({
expectedRevision: 1,
allowedWorkflowIds: [],
padding: 'x'.repeat(40_000),
})
const request = new NextRequest(url, {
method: 'PUT',
body,
headers: {
'content-length': String(Buffer.byteLength(body)),
'content-type': 'application/json',
},
})

const response = await PUT(request, context)

expect(response.status).toBe(413)
expect(mocks.update).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
getCredentialGroupAccessContract,
updateCredentialGroupAccessContract,
} from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
readCredentialGroupAccess,
updateCredentialGroupAccess,
} from '@/lib/credential-groups/application/manage-access'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'

const rateLimit = internalRateLimits.none({
reason: 'Credential Group access changes are workspace-admin control-plane operations',
})
const MAX_CREDENTIAL_GROUP_ACCESS_BODY_BYTES = 32 * 1024

export const GET = defineInternalJsonRoute({
contract: getCredentialGroupAccessContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.readAccess,
rateLimit,
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to read Credential Group access'),
mapInput: ({ params }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
}),
useCase: readCredentialGroupAccess,
})

export const PUT = defineInternalJsonRoute({
contract: updateCredentialGroupAccessContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.updateAccess,
rateLimit,
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update Credential Group access'),
parseOptions: { maxBodyBytes: MAX_CREDENTIAL_GROUP_ACCESS_BODY_BYTES },
mapInput: ({ params, body }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
expectedRevision: body.expectedRevision,
allowedWorkflowIds: body.allowedWorkflowIds,
}),
useCase: updateCredentialGroupAccess,
})
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const credentialGroupIdUrlKeys = {
/** Active view inside a credential-group detail page. */
export const credentialGroupTabParam = {
key: 'credential-group-tab',
parser: parseAsStringLiteral(['details', 'people'] as const).withDefault('details'),
parser: parseAsStringLiteral(['details', 'people', 'access'] as const).withDefault('details'),
} as const

/** Tab view-state: clean URLs, no back-stack churn. */
Expand Down
19 changes: 18 additions & 1 deletion apps/sim/components/settings/use-settings-unsaved-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'

interface UseSettingsUnsavedGuardParams {
isDirty: boolean
navigationBlocked?: boolean
}

interface SettingsUnsavedGuard {
Expand All @@ -17,27 +18,40 @@ interface SettingsUnsavedGuard {
*/
export function useSettingsUnsavedGuard({
isDirty,
navigationBlocked = false,
}: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard {
const setDirty = useSettingsDirtyStore((state) => state.setDirty)
const setNavigationBlocked = useSettingsDirtyStore((state) => state.setNavigationBlocked)
const reset = useSettingsDirtyStore((state) => state.reset)
const isDirtyRef = useRef(isDirty)
const navigationBlockedRef = useRef(navigationBlocked)
const pendingLeaveRef = useRef<(() => void) | null>(null)
const [showUnsavedModal, setShowUnsavedModal] = useState(false)

useEffect(() => {
isDirtyRef.current = isDirty
navigationBlockedRef.current = navigationBlocked
setDirty(isDirty)
setNavigationBlocked(navigationBlocked)
if (navigationBlocked) {
pendingLeaveRef.current = null
setShowUnsavedModal(false)
return
}
if (!isDirty) {
pendingLeaveRef.current = null
setShowUnsavedModal(false)
}
}, [isDirty, setDirty])
}, [isDirty, navigationBlocked, setDirty, setNavigationBlocked])

useEffect(() => {
return () => reset()
}, [reset])

const guardBack = useCallback((onLeave: () => void) => {
if (navigationBlockedRef.current || useSettingsDirtyStore.getState().navigationBlocked) {
return
}
if (isDirtyRef.current) {
pendingLeaveRef.current = onLeave
setShowUnsavedModal(true)
Expand All @@ -47,6 +61,9 @@ export function useSettingsUnsavedGuard({
}, [])

const confirmDiscard = useCallback(() => {
if (navigationBlockedRef.current || useSettingsDirtyStore.getState().navigationBlocked) {
return
}
setShowUnsavedModal(false)
pendingLeaveRef.current?.()
pendingLeaveRef.current = null
Expand Down
Loading
Loading