Skip to content
Closed
59 changes: 59 additions & 0 deletions apps/sim/app/api/environment/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @vitest-environment node
*/
import {
auditMock,
authMockFns,
createMockRequest,
dbChainMockFns,
environmentUtilsMockFns,
posthogServerMock,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockEncryptSecret, mockSyncPersonalEnvCredentialsForUser } = vi.hoisted(() => ({
mockEncryptSecret: vi.fn(),
mockSyncPersonalEnvCredentialsForUser: vi.fn(),
}))

vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: vi.fn(),
encryptSecret: mockEncryptSecret,
}))
vi.mock('@/lib/credentials/environment', () => ({
syncPersonalEnvCredentialsForUser: mockSyncPersonalEnvCredentialsForUser,
}))

import { POST } from '@/app/api/environment/route'

describe('POST /api/environment', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1', name: 'Test User', email: 'test@example.com' },
})
mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-value' })
mockSyncPersonalEnvCredentialsForUser.mockResolvedValue(undefined)
})

it('invalidates the effective environment cache immediately after the database update', async () => {
const response = await POST(
createMockRequest('POST', { variables: { JIRA_DOMAIN: 'example.atlassian.net' } })
)

expect(response.status).toBe(200)
expect(environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache).toHaveBeenCalledWith({
userId: 'user-1',
})
expect(dbChainMockFns.onConflictDoUpdate.mock.invocationCallOrder[0]).toBeLessThan(
environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache.mock.invocationCallOrder[0]
)
expect(
environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache.mock.invocationCallOrder[0]
).toBeLessThan(mockSyncPersonalEnvCredentialsForUser.mock.invocationCallOrder[0])
})
})
3 changes: 3 additions & 0 deletions apps/sim/app/api/environment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
import type { EnvironmentVariable } from '@/lib/environment/api'
import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils'
import { captureServerEvent } from '@/lib/posthog/server'

const logger = createLogger('EnvironmentAPI')
Expand Down Expand Up @@ -69,6 +70,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
},
})

invalidateEffectiveDecryptedEnvCache({ userId: session.user.id })

await syncPersonalEnvCredentialsForUser({
userId: session.user.id,
envKeys: Object.keys(variables),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ vi.mock(
() => ({ invalidateResourceQueries: vi.fn() })
)

import { SetEnvironmentVariables } from '@/lib/copilot/generated/tool-catalog-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract'
import { environmentKeys } from '@/hooks/queries/environment'
import { environmentDependentSelectorKeys } from '@/hooks/selectors/cache-invalidation'
import { dispatchStreamEvent } from './dispatch-stream-event'
import { createStreamLoopContext, type StreamLoopContext } from './stream-context'
import { makeStreamLoopDeps, ref } from './stream-test-helpers'
Expand All @@ -38,7 +41,7 @@ function toolEnv(payload: Record<string, unknown>): PersistedStreamEventEnvelope
const toolCall = (id: string, name = 'my_tool') =>
toolEnv({ phase: 'call', executor: 'go', mode: 'sync', toolCallId: id, toolName: name })

const toolResult = (id: string, success: boolean, name = 'my_tool') =>
const toolResult = (id: string, success: boolean, name = 'my_tool', output?: unknown) =>
toolEnv({
phase: 'result',
executor: 'go',
Expand All @@ -47,6 +50,7 @@ const toolResult = (id: string, success: boolean, name = 'my_tool') =>
toolName: name,
success,
status: success ? 'success' : 'error',
...(output === undefined ? {} : { output }),
})

const workspaceFileCall = (id: string) =>
Expand Down Expand Up @@ -110,6 +114,63 @@ describe('tool events (dispatch → model + side effects)', () => {
expect(toolNode(ctx, 'tc-3').status).toBe('error')
})

it.each([
{
scope: 'personal',
output: { scope: 'personal' },
environmentQueryKeys: [environmentKeys.personal(), environmentKeys.workspaces()],
},
{
scope: 'workspace',
output: { scope: 'workspace', workspaceId: 'workspace-from-result' },
environmentQueryKeys: [environmentKeys.workspace('workspace-from-result')],
},
])(
'refreshes the $scope environment before selector caches after Copilot saves secrets',
async ({ output, environmentQueryKeys }) => {
const deps = makeStreamLoopDeps()
const invalidateQueries = vi.mocked(deps.queryClient.invalidateQueries)
invalidateQueries.mockResolvedValue(undefined)
const ctx = createStreamLoopContext(deps)

dispatchStreamEvent(ctx, toolCall('environment-1', SetEnvironmentVariables.id))
dispatchStreamEvent(
ctx,
toolResult('environment-1', true, SetEnvironmentVariables.id, output)
)

await vi.waitFor(() =>
expect(invalidateQueries).toHaveBeenCalledTimes(environmentQueryKeys.length + 4)
)
expect(invalidateQueries.mock.calls.map(([filters]) => filters?.queryKey)).toEqual([
...environmentQueryKeys,
environmentDependentSelectorKeys.primary,
environmentDependentSelectorKeys.dynamicDetails,
environmentDependentSelectorKeys.workflowDetails,
environmentDependentSelectorKeys.workflowReplacementOptions,
])
}
)

it('does not refresh environment or selector caches when Copilot secret storage fails', async () => {
const deps = makeStreamLoopDeps()
const invalidateQueries = vi.mocked(deps.queryClient.invalidateQueries)
invalidateQueries.mockResolvedValue(undefined)
const ctx = createStreamLoopContext(deps)

dispatchStreamEvent(ctx, toolCall('environment-failed', SetEnvironmentVariables.id))
dispatchStreamEvent(
ctx,
toolResult('environment-failed', false, SetEnvironmentVariables.id, {
scope: 'workspace',
workspaceId: 'workspace-from-result',
})
)
await Promise.resolve()

expect(invalidateQueries).not.toHaveBeenCalled()
})

// The client starts terminal/browser/workflow tools straight off the call
// frame rather than waiting for the server to dispatch them, so a permission
// gate that only held the server would let the command run behind the prompt.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
MothershipStreamV1ToolPhase,
MothershipStreamV1ToolStatus,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { ApplyFileEdit, PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1'
import {
ApplyFileEdit,
PrepareFileEdit,
SetEnvironmentVariables,
} from '@/lib/copilot/generated/tool-catalog-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import {
extractResourcesFromToolResult,
Expand All @@ -26,8 +30,10 @@ import {
type ToolNode,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { environmentKeys } from '@/hooks/queries/environment'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { invalidateEnvironmentDependentSelectorQueries } from '@/hooks/selectors/cache-invalidation'

type ToolEvent = Extract<PersistedStreamEventEnvelope, { type: 'tool' }>

Expand Down Expand Up @@ -71,6 +77,26 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
void invalidateWorkflowLists(deps.queryClient, deps.workspaceId, ['active', 'archived'])
}

if (name === SetEnvironmentVariables.id && isSuccess) {
const out = output as Record<string, unknown> | undefined
const isPersonal = out?.scope === 'personal'
const workspaceId = typeof out?.workspaceId === 'string' ? out.workspaceId : deps.workspaceId

void (async () => {
if (isPersonal) {
await Promise.all([
deps.queryClient.invalidateQueries({ queryKey: environmentKeys.personal() }),
deps.queryClient.invalidateQueries({ queryKey: environmentKeys.workspaces() }),
])
} else {
await deps.queryClient.invalidateQueries({
queryKey: environmentKeys.workspace(workspaceId),
})
}
await invalidateEnvironmentDependentSelectorQueries(deps.queryClient)
})()
}

const extractedResources =
isSuccess && isResourceToolName(name)
? extractResourcesFromToolResult(name, params, output)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConnectorConfigField } from '@/connectors/types'
import type { SelectorDefinition, SelectorKey } from '@/hooks/selectors/types'

const { getSelectorDefinitionMock, useSelectorOptionsMock } = vi.hoisted(() => ({
getSelectorDefinitionMock: vi.fn(),
useSelectorOptionsMock: vi.fn(() => ({
data: [],
isLoading: false,
isFetching: false,
isFetchingMore: false,
hasMore: false,
truncated: false,
error: null,
})),
}))

vi.mock('@sim/emcn', () => ({ ChipCombobox: () => null }))
vi.mock('@sim/emcn/icons', () => ({ Loader: () => null }))
vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1', id: 'knowledge-1' }),
}))
vi.mock('@/hooks/selectors/registry', () => ({
getSelectorDefinition: getSelectorDefinitionMock,
}))
vi.mock('@/hooks/selectors/use-selector-query', () => ({
useSelectorOptions: useSelectorOptionsMock,
useSelectorOptionDetail: () => ({ data: null }),
useSelectorOptionDetails: () => [],
}))
vi.mock('@/hooks/use-debounce', () => ({ useDebounce: (value: string) => value }))

import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field'

let root: Root | null = null

afterEach(() => {
act(() => root?.unmount())
root = null
document.body.innerHTML = ''
vi.clearAllMocks()
})

describe('ConnectorSelectorField cache scope', () => {
it('changes only when a server-resolved selector dependency changes', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const definition = {
key: 'jira.projects' as SelectorKey,
serverResolvedContextFields: ['domain'],
getQueryKey: () => ['selectors', 'jira.projects'],
fetchList: async () => [],
} as SelectorDefinition
getSelectorDefinitionMock.mockReturnValue(definition)

const domainField: ConnectorConfigField = {
id: 'domain-field',
title: 'Domain',
type: 'short-input',
canonicalParamId: 'domain',
mode: 'basic',
}
const projectField = {
id: 'project',
title: 'Project',
type: 'selector',
selectorKey: definition.key,
dependsOn: ['domain-field'],
} satisfies ConnectorConfigField & { selectorKey: SelectorKey }
const configFields = [domainField, projectField]
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)

const render = (domain: string, unrelated: string) => {
act(() =>
root?.render(
<ConnectorSelectorField
field={projectField}
value=''
onChange={vi.fn()}
credentialId='credential-1'
sourceConfig={{ 'domain-field': domain, unrelated }}
configFields={configFields}
canonicalModes={{ domain: 'basic' }}
/>
)
)
return useSelectorOptionsMock.mock.calls.at(-1)?.[1].context.selectorCacheScope
}

const initial = render('{{JIRA_DOMAIN}}', 'first')
const afterUnrelatedEdit = render('{{JIRA_DOMAIN}}', 'second')
const afterDependencyEdit = render('{{OTHER_DOMAIN}}', 'second')

expect(initial).toEqual(expect.any(String))
expect(afterUnrelatedEdit).toBe(initial)
expect(afterDependencyEdit).not.toBe(initial)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useMemo, useState } from 'react'
import { ChipCombobox, type ComboboxOption } from '@sim/emcn'
import { Loader } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
Expand All @@ -11,6 +12,10 @@ import type {
ConfigFieldValue,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
import type { ConnectorConfigField } from '@/connectors/types'
import {
createSelectorCacheScopeRegistry,
scopeServerResolvedSelectorContext,
} from '@/hooks/selectors/context-resolution'
import { getSelectorDefinition } from '@/hooks/selectors/registry'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import {
Expand Down Expand Up @@ -41,11 +46,16 @@ export function ConnectorSelectorField({
canonicalModes,
disabled,
}: ConnectorSelectorFieldProps) {
const params = useParams<{ workspaceId: string; id: string }>()
const isMulti = Boolean(field.multi)
const [searchTerm, setSearchTerm] = useState('')
const definition = getSelectorDefinition(field.selectorKey)
const selectorCacheScopes = useMemo(() => createSelectorCacheScopeRegistry(), [])

const context = useMemo<SelectorContext>(() => {
const ctx: SelectorContext = {}
const ctx: SelectorContext = {
workspaceId: params.workspaceId,
}
if (credentialId) ctx.oauthCredential = credentialId
if (field.mimeType) ctx.mimeType = field.mimeType

Expand All @@ -59,8 +69,18 @@ export function ConnectorSelectorField({
}
}

return ctx
}, [credentialId, field.mimeType, field.dependsOn, sourceConfig, configFields, canonicalModes])
return scopeServerResolvedSelectorContext(definition, ctx, selectorCacheScopes)
}, [
credentialId,
field.mimeType,
field.dependsOn,
sourceConfig,
configFields,
canonicalModes,
params.workspaceId,
definition,
selectorCacheScopes,
])

const depsResolved = useMemo(() => {
if (!field.dependsOn) return true
Expand Down Expand Up @@ -107,7 +127,7 @@ export function ConnectorSelectorField({
* implementations resolve a record by id, where a partial keystroke is a guaranteed
* failed upstream request rather than an empty result.
*/
const resolvesUnknownIds = Boolean(getSelectorDefinition(field.selectorKey).resolvesUnknownIds)
const resolvesUnknownIds = Boolean(definition.resolvesUnknownIds)
const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS)
const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, {
context,
Expand Down
Loading
Loading