@@ -19,6 +19,7 @@ import {
1919import { beforeEach , describe , expect , it , vi } from 'vitest'
2020
2121const mocks = vi . hoisted ( ( ) => ( {
22+ authorizeCredentialUseForAuth : vi . fn ( ) ,
2223 configurePolling : vi . fn ( ) ,
2324 createExternalWebhookSubscription : vi . fn ( ) ,
2425 findConflictingWebhookPathOwner : vi . fn ( ) ,
@@ -31,6 +32,9 @@ vi.mock('@sim/audit', () => auditMock)
3132vi . mock ( '@/lib/permission-groups/config-scope.server' , ( ) => permissionGroupScopeMock )
3233vi . mock ( '@/lib/core/telemetry' , ( ) => telemetryMock )
3334vi . mock ( '@/lib/posthog/server' , ( ) => posthogServerMock )
35+ vi . mock ( '@/lib/auth/credential-access' , ( ) => ( {
36+ authorizeCredentialUseForAuth : mocks . authorizeCredentialUseForAuth ,
37+ } ) )
3438vi . mock ( '@/lib/webhooks/env-resolver' , ( ) => ( {
3539 resolveEnvVarsInObject : mocks . resolveEnvVarsInObject ,
3640} ) )
@@ -353,46 +357,75 @@ describe('POST /api/webhooks polling configuration', () => {
353357 } )
354358} )
355359
356- describe ( 'POST /api/webhooks triggers.webhook gate' , ( ) => {
357- beforeEach ( ( ) => {
358- vi . clearAllMocks ( )
359- resetDbChainMock ( )
360- authMockFns . mockGetSession . mockResolvedValue ( {
361- user : { id : 'actor-1' , name : 'Actor' , email : 'actor@example.com' } ,
362- session : { id : 'session-1' } ,
363- } )
364- workflowAuthzMockFns . mockAuthorizeWorkflowByWorkspacePermission . mockResolvedValue ( {
365- allowed : true ,
366- status : 200 ,
367- workflow : { id : 'workflow-1' } ,
368- workspacePermission : 'write' ,
369- } )
370- workflowAuthzMockFns . mockAssertWorkflowMutable . mockResolvedValue ( undefined )
371- permissionGroupScopeMockFns . mockResolvePermissionGroupConfig . mockResolvedValue ( null )
372- mocks . findConflictingWebhookPathOwner . mockResolvedValue ( null )
373- mocks . resolveEnvVarsInObject . mockImplementation ( async ( config ) => config )
374- mocks . shouldRecreateExternalWebhookSubscription . mockReturnValue ( false )
375- mocks . getProviderHandler . mockReturnValue ( { } )
376- mocks . createExternalWebhookSubscription . mockResolvedValue ( {
377- updatedProviderConfig : { } ,
378- externalSubscriptionCreated : false ,
379- } )
380- } )
360+ const CREDENTIAL_ALLOWED = { ok : true , workspaceId : 'workspace-1' }
361+ const CREDENTIAL_DENIED = { ok : false , error : 'You do not have access to this credential.' }
381362
382- function upsertRequest ( ) {
383- return createMockRequest ( 'POST' , {
363+ /** Mocks an actor with write access to `workflow-1` and no provider side effects. */
364+ function setupUpsertMocks ( ) : void {
365+ vi . clearAllMocks ( )
366+ resetDbChainMock ( )
367+ authMockFns . mockGetSession . mockResolvedValue ( {
368+ user : { id : 'actor-1' , name : 'Actor' , email : 'actor@example.com' } ,
369+ session : { id : 'session-1' } ,
370+ } )
371+ workflowAuthzMockFns . mockAuthorizeWorkflowByWorkspacePermission . mockResolvedValue ( {
372+ allowed : true ,
373+ status : 200 ,
374+ workflow : { id : 'workflow-1' } ,
375+ workspacePermission : 'write' ,
376+ } )
377+ workflowAuthzMockFns . mockAssertWorkflowMutable . mockResolvedValue ( undefined )
378+ permissionGroupScopeMockFns . mockResolvePermissionGroupConfig . mockResolvedValue ( null )
379+ mocks . findConflictingWebhookPathOwner . mockResolvedValue ( null )
380+ mocks . resolveEnvVarsInObject . mockImplementation ( async ( config ) => config )
381+ mocks . shouldRecreateExternalWebhookSubscription . mockReturnValue ( false )
382+ mocks . getProviderHandler . mockReturnValue ( { } )
383+ mocks . createExternalWebhookSubscription . mockResolvedValue ( {
384+ updatedProviderConfig : { } ,
385+ externalSubscriptionCreated : false ,
386+ } )
387+ }
388+
389+ function upsertRequest ( providerConfig : Record < string , unknown > = { } ) {
390+ return createMockRequest ( 'POST' , {
391+ workflowId : 'workflow-1' ,
392+ path : 'inbound-orders' ,
393+ provider : 'generic' ,
394+ providerConfig,
395+ } )
396+ }
397+
398+ /** The reads the create path makes, in the order the handler issues them. */
399+ function queueCreatePathRows ( ) : void {
400+ queueTableRows ( workflow , [ { id : 'workflow-1' , userId : 'actor-1' , workspaceId : 'workspace-1' } ] )
401+ queueTableRows ( webhook , [ ] )
402+ }
403+
404+ /** The reads the update path makes: the path claim, then the existing row. */
405+ function queueUpdatePathRows (
406+ isActive : boolean ,
407+ providerConfig : Record < string , unknown > = { }
408+ ) : void {
409+ queueTableRows ( workflow , [ { id : 'workflow-1' , userId : 'actor-1' , workspaceId : 'workspace-1' } ] )
410+ queueTableRows ( webhook , [ { id : 'webhook-1' } ] )
411+ queueTableRows ( webhook , [
412+ {
413+ id : 'webhook-1' ,
384414 workflowId : 'workflow-1' ,
415+ blockId : 'block-1' ,
385416 path : 'inbound-orders' ,
386417 provider : 'generic' ,
387- providerConfig : { } ,
388- } )
389- }
418+ providerConfig,
419+ isActive,
420+ } ,
421+ ] )
422+ dbChainMockFns . returning . mockImplementationOnce ( async ( ) => [
423+ { id : 'webhook-1' , workflowId : 'workflow-1' , path : 'inbound-orders' , isActive : true } ,
424+ ] )
425+ }
390426
391- /** The reads the create path makes, in the order the handler issues them. */
392- function queueCreatePathRows ( ) : void {
393- queueTableRows ( workflow , [ { id : 'workflow-1' , userId : 'actor-1' , workspaceId : 'workspace-1' } ] )
394- queueTableRows ( webhook , [ ] )
395- }
427+ describe ( 'POST /api/webhooks triggers.webhook gate' , ( ) => {
428+ beforeEach ( setupUpsertMocks )
396429
397430 /**
398431 * Making a workflow reachable from an inbound webhook is the only external
@@ -421,26 +454,6 @@ describe('POST /api/webhooks triggers.webhook gate', () => {
421454 expect ( mocks . createExternalWebhookSubscription ) . toHaveBeenCalledTimes ( 1 )
422455 } )
423456
424- /** The reads the update path makes: the path claim, then the existing row. */
425- function queueUpdatePathRows ( isActive : boolean ) : void {
426- queueTableRows ( workflow , [ { id : 'workflow-1' , userId : 'actor-1' , workspaceId : 'workspace-1' } ] )
427- queueTableRows ( webhook , [ { id : 'webhook-1' } ] )
428- queueTableRows ( webhook , [
429- {
430- id : 'webhook-1' ,
431- workflowId : 'workflow-1' ,
432- blockId : 'block-1' ,
433- path : 'inbound-orders' ,
434- provider : 'generic' ,
435- providerConfig : { } ,
436- isActive,
437- } ,
438- ] )
439- dbChainMockFns . returning . mockImplementationOnce ( async ( ) => [
440- { id : 'webhook-1' , workflowId : 'workflow-1' , path : 'inbound-orders' , isActive : true } ,
441- ] )
442- }
443-
444457 /**
445458 * The upsert always writes `isActive: true`, so re-saving a dormant webhook is
446459 * the same transition `PATCH /api/webhooks/[id]` gates — a workflow becoming
@@ -488,3 +501,205 @@ describe('POST /api/webhooks triggers.webhook gate', () => {
488501 expect ( dbChainMockFns . set ) . toHaveBeenCalledWith ( expect . objectContaining ( { isActive : true } ) )
489502 } )
490503} )
504+
505+ describe ( 'POST /api/webhooks credential references' , ( ) => {
506+ beforeEach ( setupUpsertMocks )
507+
508+ /**
509+ * Subscription setup and polling mint tokens as the credential's owner, so a
510+ * reference the actor cannot use must be refused before either runs.
511+ */
512+ it ( 'refuses a credential the actor cannot use' , async ( ) => {
513+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( {
514+ ok : false ,
515+ error : 'Credential is not accessible from this workflow workspace' ,
516+ } )
517+ queueCreatePathRows ( )
518+
519+ const response = await POST ( upsertRequest ( { credentialId : 'victim-credential' } ) )
520+
521+ expect ( response . status ) . toBe ( 403 )
522+ expect ( mocks . authorizeCredentialUseForAuth ) . toHaveBeenCalledWith (
523+ expect . objectContaining ( { success : true , userId : 'actor-1' } ) ,
524+ { credentialId : 'victim-credential' , workflowId : 'workflow-1' }
525+ )
526+ expect ( mocks . createExternalWebhookSubscription ) . not . toHaveBeenCalled ( )
527+ expect ( dbChainMockFns . values ) . not . toHaveBeenCalled ( )
528+ } )
529+
530+ it ( 'refuses a credential id supplied through an env-var reference' , async ( ) => {
531+ mocks . resolveEnvVarsInObject . mockImplementation ( async ( config ) => ( {
532+ ...config ,
533+ credentialId : 'victim-credential' ,
534+ } ) )
535+ queueCreatePathRows ( )
536+
537+ const response = await POST ( upsertRequest ( { credentialId : '{{CREDENTIAL}}' } ) )
538+
539+ expect ( response . status ) . toBe ( 400 )
540+ expect ( mocks . authorizeCredentialUseForAuth ) . not . toHaveBeenCalled ( )
541+ expect ( mocks . createExternalWebhookSubscription ) . not . toHaveBeenCalled ( )
542+ } )
543+
544+ it ( 'saves a credential the actor can use in the workflow workspace' , async ( ) => {
545+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( CREDENTIAL_ALLOWED )
546+ queueCreatePathRows ( )
547+
548+ const response = await POST ( upsertRequest ( { credentialId : 'own-credential' } ) )
549+
550+ expect ( response . status ) . toBe ( 201 )
551+ expect ( mocks . createExternalWebhookSubscription ) . toHaveBeenCalledTimes ( 1 )
552+ expect ( dbChainMockFns . values ) . toHaveBeenCalledWith (
553+ expect . objectContaining ( { providerConfig : { credentialId : 'own-credential' } } )
554+ )
555+ } )
556+
557+ /** The polling token resolver mints `providerConfig.userId`'s token when no credential is set. */
558+ it ( 'drops a client-supplied userId before subscribing or saving' , async ( ) => {
559+ queueCreatePathRows ( )
560+
561+ const response = await POST (
562+ upsertRequest ( { userId : 'victim-user' , eventType : 'record.created' } )
563+ )
564+
565+ expect ( response . status ) . toBe ( 201 )
566+ expect ( mocks . createExternalWebhookSubscription ) . toHaveBeenCalledWith (
567+ expect . anything ( ) ,
568+ expect . objectContaining ( { providerConfig : { eventType : 'record.created' } } ) ,
569+ expect . anything ( ) ,
570+ 'actor-1' ,
571+ expect . anything ( )
572+ )
573+ expect ( dbChainMockFns . values ) . toHaveBeenCalledWith (
574+ expect . objectContaining ( { providerConfig : { eventType : 'record.created' } } )
575+ )
576+ } )
577+
578+ /**
579+ * A re-save that omits `credentialId` still acts with the stored credential
580+ * (polling setup and subscription cleanup read it), so that credential is
581+ * authorized and kept, while a stored `userId` is never carried forward.
582+ */
583+ it ( 'authorizes and keeps the stored credential on a re-save that omits it' , async ( ) => {
584+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( CREDENTIAL_ALLOWED )
585+ queueUpdatePathRows ( true , { credentialId : 'stored-credential' , userId : 'stored-user' } )
586+
587+ const response = await POST ( upsertRequest ( { eventType : 'record.created' } ) )
588+
589+ expect ( response . status ) . toBe ( 200 )
590+ expect ( mocks . authorizeCredentialUseForAuth ) . toHaveBeenCalledWith ( expect . anything ( ) , {
591+ credentialId : 'stored-credential' ,
592+ workflowId : 'workflow-1' ,
593+ } )
594+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
595+ expect . objectContaining ( {
596+ providerConfig : { eventType : 'record.created' , credentialId : 'stored-credential' } ,
597+ } )
598+ )
599+ } )
600+
601+ it ( 'refuses a re-save whose stored credential the actor cannot use' , async ( ) => {
602+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( CREDENTIAL_DENIED )
603+ queueUpdatePathRows ( true , { credentialId : 'stored-credential' } )
604+
605+ const response = await POST ( upsertRequest ( { eventType : 'record.created' } ) )
606+
607+ expect ( response . status ) . toBe ( 403 )
608+ expect ( mocks . createExternalWebhookSubscription ) . not . toHaveBeenCalled ( )
609+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalled ( )
610+ } )
611+
612+ /**
613+ * Clearing `credentialId` does not stop the save from acting with the stored
614+ * credential: a recreate still cleans up the previous subscription with it.
615+ */
616+ it . each ( [ null , '' ] ) (
617+ 'still authorizes the stored credential when a re-save sends credentialId %j' ,
618+ async ( credentialId ) => {
619+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( CREDENTIAL_DENIED )
620+ mocks . shouldRecreateExternalWebhookSubscription . mockReturnValue ( true )
621+ queueUpdatePathRows ( true , { credentialId : 'stored-credential' } )
622+
623+ const response = await POST ( upsertRequest ( { credentialId } ) )
624+
625+ expect ( response . status ) . toBe ( 403 )
626+ expect ( mocks . authorizeCredentialUseForAuth ) . toHaveBeenCalledWith ( expect . anything ( ) , {
627+ credentialId : 'stored-credential' ,
628+ workflowId : 'workflow-1' ,
629+ } )
630+ expect ( mocks . createExternalWebhookSubscription ) . not . toHaveBeenCalled ( )
631+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalled ( )
632+ }
633+ )
634+
635+ /** Rotation without recreation never touches the old credential, so it needs no access to it. */
636+ it ( 'rotates the credential without access to the stored one when nothing is recreated' , async ( ) => {
637+ mocks . authorizeCredentialUseForAuth . mockImplementation ( async ( _auth , { credentialId } ) =>
638+ credentialId === 'new-credential' ? CREDENTIAL_ALLOWED : CREDENTIAL_DENIED
639+ )
640+ queueUpdatePathRows ( true , { credentialId : 'stored-credential' } )
641+
642+ const response = await POST ( upsertRequest ( { credentialId : 'new-credential' } ) )
643+
644+ expect ( response . status ) . toBe ( 200 )
645+ expect ( mocks . authorizeCredentialUseForAuth . mock . calls . map ( ( [ , params ] ) => params ) ) . toEqual ( [
646+ { credentialId : 'new-credential' , workflowId : 'workflow-1' } ,
647+ ] )
648+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
649+ expect . objectContaining ( { providerConfig : { credentialId : 'new-credential' } } )
650+ )
651+ } )
652+
653+ /** Recreation cleans up the previous subscription with the stored credential. */
654+ it ( 'authorizes both credentials when a rotation recreates the subscription' , async ( ) => {
655+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( CREDENTIAL_ALLOWED )
656+ mocks . shouldRecreateExternalWebhookSubscription . mockReturnValue ( true )
657+ queueUpdatePathRows ( true , { credentialId : 'stored-credential' } )
658+
659+ const response = await POST ( upsertRequest ( { credentialId : 'new-credential' } ) )
660+
661+ expect ( response . status ) . toBe ( 200 )
662+ expect ( mocks . authorizeCredentialUseForAuth . mock . calls . map ( ( [ , params ] ) => params ) ) . toEqual ( [
663+ { credentialId : 'new-credential' , workflowId : 'workflow-1' } ,
664+ { credentialId : 'stored-credential' , workflowId : 'workflow-1' } ,
665+ ] )
666+ } )
667+
668+ /**
669+ * Recreation cleans up the previous subscription with the stored credential even
670+ * when the request omits it, and a `userId` echoed back by the provider is not saved.
671+ */
672+ it ( 'authorizes the stored credential and drops userId when an omitting re-save recreates' , async ( ) => {
673+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( CREDENTIAL_ALLOWED )
674+ mocks . shouldRecreateExternalWebhookSubscription . mockReturnValue ( true )
675+ mocks . createExternalWebhookSubscription . mockResolvedValue ( {
676+ updatedProviderConfig : { externalId : 'subscription-2' , userId : 'stored-user' } ,
677+ externalSubscriptionCreated : true ,
678+ } )
679+ queueUpdatePathRows ( true , { credentialId : 'stored-credential' , userId : 'stored-user' } )
680+
681+ const response = await POST ( upsertRequest ( { eventType : 'record.created' } ) )
682+
683+ expect ( response . status ) . toBe ( 200 )
684+ expect ( mocks . authorizeCredentialUseForAuth ) . toHaveBeenCalledWith ( expect . anything ( ) , {
685+ credentialId : 'stored-credential' ,
686+ workflowId : 'workflow-1' ,
687+ } )
688+ const savedConfig = dbChainMockFns . set . mock . calls . at ( - 1 ) ?. [ 0 ] . providerConfig
689+ expect ( savedConfig ) . toEqual ( { eventType : 'record.created' , externalId : 'subscription-2' } )
690+ } )
691+
692+ /** The permission-group refusal keeps answering first, before any credential lookup. */
693+ it ( 'refuses a withheld creation before authorizing its credential' , async ( ) => {
694+ permissionGroupScopeMockFns . mockResolvePermissionGroupConfig . mockResolvedValue ( {
695+ ...DEFAULT_PERMISSION_GROUP_CONFIG ,
696+ disableWebhookTriggers : true ,
697+ } )
698+ queueCreatePathRows ( )
699+
700+ const response = await POST ( upsertRequest ( { credentialId : 'victim-credential' } ) )
701+
702+ expect ( response . status ) . toBe ( 403 )
703+ expect ( mocks . authorizeCredentialUseForAuth ) . not . toHaveBeenCalled ( )
704+ } )
705+ } )
0 commit comments