22 * @vitest -environment node
33 */
44import { envFlagsMock } from '@sim/testing'
5- import { beforeEach , describe , expect , it , vi } from 'vitest'
5+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
66import { normalizeConditionRouterIds } from './builders'
77
8- const { mockValidateSelectorIds, mockGetModelOptions, mockGetCustomToolById, mockGetSkillById } =
9- vi . hoisted ( ( ) => ( {
10- mockValidateSelectorIds : vi . fn ( ) ,
11- mockGetModelOptions : vi . fn ( ( ) => [ ] ) ,
12- mockGetCustomToolById : vi . fn ( ) ,
13- mockGetSkillById : vi . fn ( ) ,
14- } ) )
8+ const {
9+ mockValidateSelectorIds,
10+ mockGetModelOptions,
11+ mockEnvFlags,
12+ mockGetTool,
13+ mockGetCustomToolById,
14+ mockGetSkillById,
15+ } = vi . hoisted ( ( ) => ( {
16+ mockValidateSelectorIds : vi . fn ( ) ,
17+ mockGetModelOptions : vi . fn ( ( ) => [ ] ) ,
18+ mockEnvFlags : { isHosted : false } ,
19+ mockGetTool : vi . fn ( ) ,
20+ mockGetCustomToolById : vi . fn ( ) ,
21+ mockGetSkillById : vi . fn ( ) ,
22+ } ) )
1523
1624const conditionBlockConfig = {
1725 type : 'condition' ,
@@ -73,6 +81,53 @@ const canonicalCredBlockConfig = {
7381 ] ,
7482}
7583
84+ // Mirrors video_generator_v3: routes provider -> tool; only video_falai has hosting.
85+ const videoBlockConfig = {
86+ type : 'video_generator_v3' ,
87+ name : 'Video Generator' ,
88+ outputs : { } ,
89+ subBlocks : [ { id : 'provider' , type : 'dropdown' } ] ,
90+ tools : {
91+ access : [ 'video_runway' , 'video_falai' ] ,
92+ config : {
93+ tool : ( params : Record < string , unknown > ) =>
94+ params . provider === 'falai' ? 'video_falai' : 'video_runway' ,
95+ } ,
96+ } ,
97+ }
98+
99+ // A hosted block whose tool's managed key param is NOT named 'apiKey'.
100+ const customKeyBlockConfig = {
101+ type : 'custom_key_block' ,
102+ name : 'Custom Key Block' ,
103+ outputs : { } ,
104+ subBlocks : [ { id : 'serviceKey' , type : 'short-input' } ] ,
105+ tools : { access : [ 'custom_key_tool' ] , config : { tool : ( ) => 'custom_key_tool' } } ,
106+ }
107+
108+ // Single tool with a per-provider `enabled` gate (mirrors image_generate, falai-only hosting).
109+ const imageBlockConfig = {
110+ type : 'image_generator_v2' ,
111+ name : 'Image Generator' ,
112+ outputs : { } ,
113+ subBlocks : [ { id : 'provider' , type : 'dropdown' } ] ,
114+ tools : { access : [ 'image_generate' ] , config : { tool : ( ) => 'image_generate' } } ,
115+ }
116+
117+ // Tool registry stand-in for the hosted-tool tests.
118+ const toolsByIdMock : Record < string , unknown > = {
119+ video_falai : { id : 'video_falai' , hosting : { apiKeyParam : 'apiKey' } } ,
120+ video_runway : { id : 'video_runway' } ,
121+ custom_key_tool : { id : 'custom_key_tool' , hosting : { apiKeyParam : 'serviceKey' } } ,
122+ image_generate : {
123+ id : 'image_generate' ,
124+ hosting : {
125+ apiKeyParam : 'apiKey' ,
126+ enabled : ( p : Record < string , unknown > ) => p . provider === 'falai' ,
127+ } ,
128+ } ,
129+ }
130+
76131vi . mock ( '@/blocks/registry' , ( ) => ( {
77132 getBlock : ( type : string ) =>
78133 type === 'condition'
@@ -89,13 +144,23 @@ vi.mock('@/blocks/registry', () => ({
89144 ? knowledgeBlockConfig
90145 : type === 'canonicalcred'
91146 ? canonicalCredBlockConfig
92- : undefined ,
147+ : type === 'video_generator_v3'
148+ ? videoBlockConfig
149+ : type === 'custom_key_block'
150+ ? customKeyBlockConfig
151+ : type === 'image_generator_v2'
152+ ? imageBlockConfig
153+ : undefined ,
93154} ) )
94155
95156vi . mock ( '@/blocks/utils' , ( ) => ( {
96157 getModelOptions : mockGetModelOptions ,
97158} ) )
98159
160+ vi . mock ( '@/tools/utils' , ( ) => ( {
161+ getTool : mockGetTool ,
162+ } ) )
163+
99164vi . mock ( '@/lib/copilot/validation/selector-validator' , ( ) => ( {
100165 validateSelectorIds : mockValidateSelectorIds ,
101166} ) )
@@ -108,7 +173,12 @@ vi.mock('@/lib/workflows/skills/operations', () => ({
108173 getSkillById : mockGetSkillById ,
109174} ) )
110175
111- vi . mock ( '@/lib/core/config/env-flags' , ( ) => envFlagsMock )
176+ vi . mock ( '@/lib/core/config/env-flags' , ( ) => ( {
177+ ...envFlagsMock ,
178+ get isHosted ( ) {
179+ return mockEnvFlags . isHosted
180+ } ,
181+ } ) )
112182
113183vi . mock ( '@/providers/utils' , ( ) => ( {
114184 getHostedModels : ( ) => [ ] ,
@@ -321,6 +391,187 @@ describe('preValidateCredentialInputs', () => {
321391 } )
322392} )
323393
394+ describe ( 'preValidateCredentialInputs (hosted-tool blocks)' , ( ) => {
395+ beforeEach ( ( ) => {
396+ vi . clearAllMocks ( )
397+ mockValidateSelectorIds . mockResolvedValue ( { valid : [ ] , invalid : [ ] } )
398+ mockGetTool . mockImplementation ( ( id : string ) => toolsByIdMock [ id ] )
399+ mockEnvFlags . isHosted = true
400+ } )
401+
402+ afterEach ( ( ) => {
403+ mockEnvFlags . isHosted = false
404+ } )
405+
406+ const ctx = { userId : 'user-1' , workspaceId : 'workspace-1' }
407+
408+ it ( 'strips apiKey when the block resolves to a hosted tool on hosted Sim' , async ( ) => {
409+ const operations = [
410+ {
411+ operation_type : 'add' as const ,
412+ block_id : 'video-1' ,
413+ params : {
414+ type : 'video_generator_v3' ,
415+ inputs : { provider : 'falai' , model : 'veo-3.1' , apiKey : '{{FAL_API_KEY}}' } ,
416+ } ,
417+ } ,
418+ ]
419+
420+ const result = await preValidateCredentialInputs ( operations , ctx )
421+
422+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBeUndefined ( )
423+ expect ( result . errors ) . toHaveLength ( 1 )
424+ expect ( result . errors [ 0 ] ) . toMatchObject ( { blockId : 'video-1' , field : 'apiKey' } )
425+ expect ( result . errors [ 0 ] ?. error ) . toContain ( 'managed by Sim' )
426+ } )
427+
428+ it ( 'preserves apiKey when the resolved tool has no hosting (non-falai provider)' , async ( ) => {
429+ const operations = [
430+ {
431+ operation_type : 'add' as const ,
432+ block_id : 'video-1' ,
433+ params : {
434+ type : 'video_generator_v3' ,
435+ inputs : { provider : 'runway' , apiKey : 'user-runway-key' } ,
436+ } ,
437+ } ,
438+ ]
439+
440+ const result = await preValidateCredentialInputs ( operations , ctx )
441+
442+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBe ( 'user-runway-key' )
443+ expect ( result . errors ) . toHaveLength ( 0 )
444+ } )
445+
446+ it ( 'resolves provider from existing block state for edit ops that only set apiKey' , async ( ) => {
447+ const operations = [
448+ {
449+ operation_type : 'edit' as const ,
450+ block_id : 'video-1' ,
451+ params : {
452+ type : 'video_generator_v3' ,
453+ inputs : { apiKey : '{{FAL_API_KEY}}' } ,
454+ } ,
455+ } ,
456+ ]
457+ const workflowState = {
458+ blocks : {
459+ 'video-1' : {
460+ type : 'video_generator_v3' ,
461+ subBlocks : { provider : { value : 'falai' } } ,
462+ } ,
463+ } ,
464+ }
465+
466+ const result = await preValidateCredentialInputs ( operations , ctx , workflowState )
467+
468+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBeUndefined ( )
469+ expect ( result . errors ) . toHaveLength ( 1 )
470+ } )
471+
472+ it ( 'strips apiKey on a hosted-tool block nested inside a loop' , async ( ) => {
473+ const operations = [
474+ {
475+ operation_type : 'add' as const ,
476+ block_id : 'loop-1' ,
477+ params : {
478+ type : 'loop' ,
479+ inputs : { } ,
480+ nestedNodes : {
481+ 'video-child' : {
482+ type : 'video_generator_v3' ,
483+ inputs : { provider : 'falai' , model : 'veo-3.1' , apiKey : '{{FAL_API_KEY}}' } ,
484+ } ,
485+ } ,
486+ } ,
487+ } ,
488+ ]
489+
490+ const result = await preValidateCredentialInputs ( operations , ctx )
491+
492+ const nested = result . filteredOperations [ 0 ] ?. params ?. nestedNodes as
493+ | Record < string , { inputs ?: Record < string , unknown > } >
494+ | undefined
495+ expect ( nested ?. [ 'video-child' ] ?. inputs ?. apiKey ) . toBeUndefined ( )
496+ expect ( result . errors ) . toHaveLength ( 1 )
497+ expect ( result . errors [ 0 ] ) . toMatchObject ( { blockId : 'video-child' , field : 'apiKey' } )
498+ } )
499+
500+ it ( "strips a hosted tool's key field even when it is not named apiKey" , async ( ) => {
501+ const operations = [
502+ {
503+ operation_type : 'add' as const ,
504+ block_id : 'custom-1' ,
505+ params : {
506+ type : 'custom_key_block' ,
507+ inputs : { serviceKey : '{{SOME_SERVICE_KEY}}' } ,
508+ } ,
509+ } ,
510+ ]
511+
512+ const result = await preValidateCredentialInputs ( operations , ctx )
513+
514+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. serviceKey ) . toBeUndefined ( )
515+ expect ( result . errors ) . toHaveLength ( 1 )
516+ expect ( result . errors [ 0 ] ) . toMatchObject ( { blockId : 'custom-1' , field : 'serviceKey' } )
517+ } )
518+
519+ it ( 'preserves apiKey on self-hosted deployments (isHosted false)' , async ( ) => {
520+ mockEnvFlags . isHosted = false
521+ const operations = [
522+ {
523+ operation_type : 'add' as const ,
524+ block_id : 'video-1' ,
525+ params : {
526+ type : 'video_generator_v3' ,
527+ inputs : { provider : 'falai' , model : 'veo-3.1' , apiKey : '{{FAL_API_KEY}}' } ,
528+ } ,
529+ } ,
530+ ]
531+
532+ const result = await preValidateCredentialInputs ( operations , ctx )
533+
534+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBe ( '{{FAL_API_KEY}}' )
535+ expect ( result . errors ) . toHaveLength ( 0 )
536+ } )
537+
538+ it ( 'strips apiKey when the tool hosting enabled gate passes (image, falai)' , async ( ) => {
539+ const operations = [
540+ {
541+ operation_type : 'add' as const ,
542+ block_id : 'image-1' ,
543+ params : {
544+ type : 'image_generator_v2' ,
545+ inputs : { provider : 'falai' , apiKey : '{{FAL_API_KEY}}' } ,
546+ } ,
547+ } ,
548+ ]
549+
550+ const result = await preValidateCredentialInputs ( operations , ctx )
551+
552+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBeUndefined ( )
553+ expect ( result . errors ) . toHaveLength ( 1 )
554+ } )
555+
556+ it ( 'preserves apiKey when the tool hosting enabled gate fails (image, non-falai)' , async ( ) => {
557+ const operations = [
558+ {
559+ operation_type : 'add' as const ,
560+ block_id : 'image-1' ,
561+ params : {
562+ type : 'image_generator_v2' ,
563+ inputs : { provider : 'openai' , apiKey : 'user-openai-key' } ,
564+ } ,
565+ } ,
566+ ]
567+
568+ const result = await preValidateCredentialInputs ( operations , ctx )
569+
570+ expect ( result . filteredOperations [ 0 ] ?. params ?. inputs ?. apiKey ) . toBe ( 'user-openai-key' )
571+ expect ( result . errors ) . toHaveLength ( 0 )
572+ } )
573+ } )
574+
324575const CTX = { userId : 'user-1' , workspaceId : 'workspace-1' }
325576
326577describe ( 'validateWorkflowSelectorIds (credential inclusion)' , ( ) => {
0 commit comments