@@ -16,6 +16,7 @@ const {
1616 mockParams,
1717 mockCredentialHost,
1818 mockOrganizationContext,
19+ mockSession,
1920 mockRefetchPersonalEnvironment,
2021 mockRefetchWorkspaceCredentials,
2122 mockIsBrowserAgentAvailable,
@@ -29,6 +30,7 @@ const {
2930} = vi . hoisted ( ( ) => ( {
3031 mockParams : vi . fn ( ( ) => ( { workspaceId : 'workspace-1' } ) ) ,
3132 mockOrganizationContext : vi . fn ( ( ) => null ) ,
33+ mockSession : vi . fn ( ( ) => ( { data : { user : { id : 'person' } } } ) ) ,
3234 mockCredentialHost : vi . fn ( ( { children } : { children : ReactNode } ) => children ) ,
3335 mockUpdateWorkspaceCredential : vi . fn ( async ( ) => undefined ) ,
3436 mockRefetchPersonalEnvironment : vi . fn ( async ( ) => ( { data : { } } ) ) ,
@@ -69,7 +71,7 @@ vi.mock('next/navigation', () => ({
6971} ) )
7072
7173vi . mock ( '@/lib/auth/auth-client' , ( ) => ( {
72- useSession : ( ) => ( { data : { user : { id : 'person' } } } ) ,
74+ useSession : mockSession ,
7375} ) )
7476vi . mock ( '@/app/workspace/[workspaceId]/home/components/chat-surface-context' , ( ) => ( {
7577 useChatSurface : ( ) => ( {
@@ -151,6 +153,7 @@ describe('CredentialDisplay link tag', () => {
151153 vi . clearAllMocks ( )
152154 mockParams . mockReturnValue ( { workspaceId : 'workspace-1' } )
153155 mockOrganizationContext . mockReturnValue ( null )
156+ mockSession . mockReturnValue ( { data : { user : { id : 'person' } } } )
154157 mockCredentialHost . mockImplementation ( ( { children } : { children : ReactNode } ) => children )
155158 window . localStorage . clear ( )
156159 window . history . replaceState ( { } , '' , '/workspace/workspace-1/chat/chat-1' )
@@ -326,6 +329,170 @@ describe('CredentialDisplay link tag', () => {
326329 expect ( fetch ) . not . toHaveBeenCalled ( )
327330 } )
328331
332+ it . each ( [ 'restricted' , 'missing' ] as const ) (
333+ 'keeps personal credentials usable when organization secrets are %s' ,
334+ async ( availability ) => {
335+ setSource ( availability === 'missing' ? null : { id : 'source' , mode : 'organization' } )
336+ mockOrganizationContext . mockReturnValue ( {
337+ organization : { id : 'org' , name : 'Example' } ,
338+ viewer : { isAdmin : false } ,
339+ } as never )
340+ render ( [ secret , { type : 'secret_input' , name : 'PERSONAL_KEY' , scope : 'personal' } ] )
341+ expect ( container . textContent ) . toContain ( 'Ask an organization admin' )
342+ expect ( container . querySelector ( 'input[aria-label="SERVICE_API_KEY"]' ) ) . toBeNull ( )
343+ enter ( 'PERSONAL_KEY' , 'personal-only' )
344+ await submit ( )
345+ expect ( mockSavePersonalEnvironment ) . toHaveBeenCalledExactlyOnceWith ( {
346+ variables : { PERSONAL_KEY : 'personal-only' } ,
347+ } )
348+ expect ( fetch ) . not . toHaveBeenCalled ( )
349+ expect ( onContinue ) . toHaveBeenCalledWith (
350+ 'Credential setup submitted — {"integrations":[],"secrets":[{"name":"SERVICE_API_KEY","status":"skipped"},{"name":"PERSONAL_KEY","status":"saved"}]}'
351+ )
352+ }
353+ )
354+
355+ it ( 'preserves personal drafts while loading and changing the organization source' , async ( ) => {
356+ vi . mocked ( fetch ) . mockReturnValue ( new Promise ( ( ) => { } ) )
357+ render ( [ secret , { type : 'secret_input' , name : 'PERSONAL_KEY' , scope : 'personal' } ] )
358+ expect ( container . textContent ) . toContain ( 'Loading Generic Secrets' )
359+ enter ( 'PERSONAL_KEY' , 'personal-only' )
360+ await act ( async ( ) => {
361+ setSource ( { id : 'source' , mode : 'member' } )
362+ await sleep ( 0 )
363+ } )
364+ expect (
365+ container . querySelector < HTMLInputElement > ( 'input[aria-label="PERSONAL_KEY"]' ) ?. value
366+ ) . toBe ( 'personal-only' )
367+ enter ( )
368+ await act ( async ( ) => {
369+ setSource ( { id : 'replacement' , mode : 'organization' } )
370+ await sleep ( 0 )
371+ } )
372+ expect (
373+ container . querySelector < HTMLInputElement > ( 'input[aria-label="SERVICE_API_KEY"]' ) ?. value
374+ ) . toBe ( '' )
375+ await submit ( )
376+ expect ( mockSavePersonalEnvironment ) . toHaveBeenCalledExactlyOnceWith ( {
377+ variables : { PERSONAL_KEY : 'personal-only' } ,
378+ } )
379+ expect (
380+ vi . mocked ( fetch ) . mock . calls . filter ( ( [ , init ] ) => init ?. method === 'PATCH' )
381+ ) . toHaveLength ( 0 )
382+ expect ( onContinue . mock . calls [ 0 ] [ 0 ] ) . toContain ( '"name":"SERVICE_API_KEY","status":"skipped"' )
383+ expect ( onContinue . mock . calls [ 0 ] [ 0 ] ) . toContain ( '"name":"PERSONAL_KEY","status":"saved"' )
384+ } )
385+
386+ it ( 'keeps personal and integration controls visible when organization metadata fails' , async ( ) => {
387+ vi . mocked ( fetch ) . mockRejectedValue ( new Error ( 'Metadata unavailable' ) )
388+ mockUseUserPermissionsContext . mockReturnValue ( { canEdit : true } )
389+ render ( [
390+ secret ,
391+ { type : 'secret_input' , name : 'PERSONAL_KEY' , scope : 'personal' } ,
392+ {
393+ type : 'link' ,
394+ provider : 'slack' ,
395+ value : 'https://example.test/connect?workspaceId=target' ,
396+ workspaceId : 'target' ,
397+ scope : 'organization' ,
398+ } ,
399+ ] )
400+ await act ( async ( ) => {
401+ await queryClient . refetchQueries ( { queryKey : organizationSecretKeys . source ( 'org' ) } )
402+ await sleep ( 0 )
403+ } )
404+ expect ( container . textContent ) . toContain ( 'Could not load Generic Secrets' )
405+ expect ( container . querySelector ( 'input[aria-label="PERSONAL_KEY"]' ) ) . not . toBeNull ( )
406+ expect ( container . querySelector ( 'input[aria-label="SERVICE_API_KEY"]' ) ) . toBeNull ( )
407+ expect (
408+ container . querySelector ( 'a[href="https://example.test/connect?workspaceId=target"]' )
409+ ) . not . toBeNull ( )
410+ } )
411+
412+ it ( 'keeps organization recap rows after submission when no input host is mounted' , ( ) => {
413+ act ( ( ) =>
414+ root . render (
415+ < QueryClientProvider client = { queryClient } >
416+ < SpecialTags
417+ segment = { { type : 'credential' , data : [ secret ] } }
418+ requestMode = 'plan'
419+ credentialSubmission = { {
420+ integrations : [ ] ,
421+ secrets : [ { name : 'SERVICE_API_KEY' , status : 'saved' } ] ,
422+ } }
423+ />
424+ </ QueryClientProvider >
425+ )
426+ )
427+ expect ( container . textContent ) . toContain ( 'SERVICE_API_KEY' )
428+ expect ( container . textContent ) . toContain ( 'Added' )
429+ expect ( container . querySelector ( 'input' ) ) . toBeNull ( )
430+ } )
431+
432+ it ( 'clears every draft when the authenticated user changes' , ( ) => {
433+ setSource ( { id : 'source' , mode : 'member' } )
434+ const data : CredentialItemData [ ] = [
435+ secret ,
436+ { type : 'secret_input' , name : 'PERSONAL_KEY' , scope : 'personal' } ,
437+ ]
438+ render ( data )
439+ enter ( )
440+ enter ( 'PERSONAL_KEY' , 'personal-only' )
441+ mockSession . mockReturnValue ( { data : { user : { id : 'another-person' } } } )
442+ render ( data )
443+ expect (
444+ container . querySelector < HTMLInputElement > ( 'input[aria-label="SERVICE_API_KEY"]' ) ?. value
445+ ) . toBe ( '' )
446+ expect (
447+ container . querySelector < HTMLInputElement > ( 'input[aria-label="PERSONAL_KEY"]' ) ?. value
448+ ) . toBe ( '' )
449+ } )
450+
451+ it ( 'does not complete the new source with an old pending save or discard unrelated saved progress' , async ( ) => {
452+ let finishSave : ( response : Response ) => void = ( ) => {
453+ throw new Error ( 'Save not started' )
454+ }
455+ vi . mocked ( fetch ) . mockImplementationOnce (
456+ ( ) =>
457+ new Promise < Response > ( ( resolve ) => {
458+ finishSave = resolve
459+ } )
460+ )
461+ setSource ( { id : 'source' , mode : 'member' } )
462+ render ( [ secret , { type : 'secret_input' , name : 'PERSONAL_KEY' , scope : 'personal' } ] )
463+ enter ( )
464+ enter ( 'PERSONAL_KEY' , 'personal-only' )
465+ act ( ( ) => {
466+ Array . from ( container . querySelectorAll ( 'button' ) )
467+ . find ( ( button ) => button . textContent === 'Submit' )
468+ ?. click ( )
469+ } )
470+ await act ( async ( ) => {
471+ await sleep ( 0 )
472+ setSource ( { id : 'replacement' , mode : 'organization' } )
473+ await sleep ( 0 )
474+ } )
475+ await act ( async ( ) => {
476+ finishSave ( new Response ( JSON . stringify ( { success : true } ) , { status : 200 } ) )
477+ await sleep ( 0 )
478+ } )
479+ expect ( onContinue ) . not . toHaveBeenCalled ( )
480+ expect (
481+ container . querySelector < HTMLInputElement > ( 'input[aria-label="SERVICE_API_KEY"]' ) ?. value
482+ ) . toBe ( '' )
483+ expect ( container . querySelector ( 'input[aria-label="PERSONAL_KEY"]' ) ) . toBeNull ( )
484+ expect ( container . textContent ) . toContain ( 'PERSONAL_KEY' )
485+ expect ( container . textContent ) . toContain ( 'Added' )
486+ enter ( 'SERVICE_API_KEY' , 'new-source-only' )
487+ await submit ( )
488+ expect ( mockSavePersonalEnvironment ) . toHaveBeenCalledOnce ( )
489+ expect ( onContinue ) . toHaveBeenCalledOnce ( )
490+ expect ( JSON . parse ( String ( vi . mocked ( fetch ) . mock . calls . at ( - 1 ) ?. [ 1 ] ?. body ) ) ) . toMatchObject ( {
491+ sourceId : 'replacement' ,
492+ upsert : { SERVICE_API_KEY : 'new-source-only' } ,
493+ } )
494+ } )
495+
329496 it ( 'never renders organization inputs in Search mode or a workspace conversation' , ( ) => {
330497 setSource ( { id : 'source' , mode : 'organization' } )
331498 render ( [ secret ] , 'assistant' )
0 commit comments