@@ -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,72 @@ 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- } )
360+ /** Mocks an actor with write access to `workflow-1` and no provider side effects. */
361+ function setupUpsertMocks ( ) : void {
362+ vi . clearAllMocks ( )
363+ resetDbChainMock ( )
364+ authMockFns . mockGetSession . mockResolvedValue ( {
365+ user : { id : 'actor-1' , name : 'Actor' , email : 'actor@example.com' } ,
366+ session : { id : 'session-1' } ,
380367 } )
381-
382- function upsertRequest ( ) {
383- return createMockRequest ( 'POST' , {
368+ workflowAuthzMockFns . mockAuthorizeWorkflowByWorkspacePermission . mockResolvedValue ( {
369+ allowed : true ,
370+ status : 200 ,
371+ workflow : { id : 'workflow-1' } ,
372+ workspacePermission : 'write' ,
373+ } )
374+ workflowAuthzMockFns . mockAssertWorkflowMutable . mockResolvedValue ( undefined )
375+ permissionGroupScopeMockFns . mockResolvePermissionGroupConfig . mockResolvedValue ( null )
376+ mocks . findConflictingWebhookPathOwner . mockResolvedValue ( null )
377+ mocks . resolveEnvVarsInObject . mockImplementation ( async ( config ) => config )
378+ mocks . shouldRecreateExternalWebhookSubscription . mockReturnValue ( false )
379+ mocks . getProviderHandler . mockReturnValue ( { } )
380+ mocks . createExternalWebhookSubscription . mockResolvedValue ( {
381+ updatedProviderConfig : { } ,
382+ externalSubscriptionCreated : false ,
383+ } )
384+ }
385+
386+ function upsertRequest ( providerConfig : Record < string , unknown > = { } ) {
387+ return createMockRequest ( 'POST' , {
388+ workflowId : 'workflow-1' ,
389+ path : 'inbound-orders' ,
390+ provider : 'generic' ,
391+ providerConfig,
392+ } )
393+ }
394+
395+ /** The reads the create path makes, in the order the handler issues them. */
396+ function queueCreatePathRows ( ) : void {
397+ queueTableRows ( workflow , [ { id : 'workflow-1' , userId : 'actor-1' , workspaceId : 'workspace-1' } ] )
398+ queueTableRows ( webhook , [ ] )
399+ }
400+
401+ /** The reads the update path makes: the path claim, then the existing row. */
402+ function queueUpdatePathRows (
403+ isActive : boolean ,
404+ providerConfig : Record < string , unknown > = { }
405+ ) : void {
406+ queueTableRows ( workflow , [ { id : 'workflow-1' , userId : 'actor-1' , workspaceId : 'workspace-1' } ] )
407+ queueTableRows ( webhook , [ { id : 'webhook-1' } ] )
408+ queueTableRows ( webhook , [
409+ {
410+ id : 'webhook-1' ,
384411 workflowId : 'workflow-1' ,
412+ blockId : 'block-1' ,
385413 path : 'inbound-orders' ,
386414 provider : 'generic' ,
387- providerConfig : { } ,
388- } )
389- }
415+ providerConfig,
416+ isActive,
417+ } ,
418+ ] )
419+ dbChainMockFns . returning . mockImplementationOnce ( async ( ) => [
420+ { id : 'webhook-1' , workflowId : 'workflow-1' , path : 'inbound-orders' , isActive : true } ,
421+ ] )
422+ }
390423
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- }
424+ describe ( 'POST /api/webhooks triggers.webhook gate' , ( ) => {
425+ beforeEach ( setupUpsertMocks )
396426
397427 /**
398428 * Making a workflow reachable from an inbound webhook is the only external
@@ -421,26 +451,6 @@ describe('POST /api/webhooks triggers.webhook gate', () => {
421451 expect ( mocks . createExternalWebhookSubscription ) . toHaveBeenCalledTimes ( 1 )
422452 } )
423453
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-
444454 /**
445455 * The upsert always writes `isActive: true`, so re-saving a dormant webhook is
446456 * the same transition `PATCH /api/webhooks/[id]` gates — a workflow becoming
@@ -488,3 +498,101 @@ describe('POST /api/webhooks triggers.webhook gate', () => {
488498 expect ( dbChainMockFns . set ) . toHaveBeenCalledWith ( expect . objectContaining ( { isActive : true } ) )
489499 } )
490500} )
501+
502+ describe ( 'POST /api/webhooks credential references' , ( ) => {
503+ beforeEach ( setupUpsertMocks )
504+
505+ /**
506+ * Subscription setup and polling mint tokens as the credential's owner, so a
507+ * reference the actor cannot use must be refused before either runs.
508+ */
509+ it ( 'refuses a credential the actor cannot use' , async ( ) => {
510+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( {
511+ ok : false ,
512+ error : 'Credential is not accessible from this workflow workspace' ,
513+ } )
514+ queueCreatePathRows ( )
515+
516+ const response = await POST ( upsertRequest ( { credentialId : 'victim-credential' } ) )
517+
518+ expect ( response . status ) . toBe ( 403 )
519+ expect ( mocks . authorizeCredentialUseForAuth ) . toHaveBeenCalledWith (
520+ expect . objectContaining ( { success : true , userId : 'actor-1' } ) ,
521+ { credentialId : 'victim-credential' , workflowId : 'workflow-1' }
522+ )
523+ expect ( mocks . createExternalWebhookSubscription ) . not . toHaveBeenCalled ( )
524+ expect ( dbChainMockFns . values ) . not . toHaveBeenCalled ( )
525+ } )
526+
527+ it ( 'refuses a credential id supplied through an env-var reference' , async ( ) => {
528+ mocks . resolveEnvVarsInObject . mockImplementation ( async ( config ) => ( {
529+ ...config ,
530+ credentialId : 'victim-credential' ,
531+ } ) )
532+ queueCreatePathRows ( )
533+
534+ const response = await POST ( upsertRequest ( { credentialId : '{{CREDENTIAL}}' } ) )
535+
536+ expect ( response . status ) . toBe ( 400 )
537+ expect ( mocks . authorizeCredentialUseForAuth ) . not . toHaveBeenCalled ( )
538+ expect ( mocks . createExternalWebhookSubscription ) . not . toHaveBeenCalled ( )
539+ } )
540+
541+ it ( 'saves a credential the actor can use in the workflow workspace' , async ( ) => {
542+ mocks . authorizeCredentialUseForAuth . mockResolvedValue ( { ok : true , workspaceId : 'workspace-1' } )
543+ queueCreatePathRows ( )
544+
545+ const response = await POST ( upsertRequest ( { credentialId : 'own-credential' } ) )
546+
547+ expect ( response . status ) . toBe ( 201 )
548+ expect ( mocks . createExternalWebhookSubscription ) . toHaveBeenCalledTimes ( 1 )
549+ expect ( dbChainMockFns . values ) . toHaveBeenCalledWith (
550+ expect . objectContaining ( { providerConfig : { credentialId : 'own-credential' } } )
551+ )
552+ } )
553+
554+ /** The polling token resolver mints `providerConfig.userId`'s token when no credential is set. */
555+ it ( 'drops a client-supplied userId before subscribing or saving' , async ( ) => {
556+ queueCreatePathRows ( )
557+
558+ const response = await POST (
559+ upsertRequest ( { userId : 'victim-user' , eventType : 'record.created' } )
560+ )
561+
562+ expect ( response . status ) . toBe ( 201 )
563+ expect ( mocks . createExternalWebhookSubscription ) . toHaveBeenCalledWith (
564+ expect . anything ( ) ,
565+ expect . objectContaining ( { providerConfig : { eventType : 'record.created' } } ) ,
566+ expect . anything ( ) ,
567+ 'actor-1' ,
568+ expect . anything ( )
569+ )
570+ expect ( dbChainMockFns . values ) . toHaveBeenCalledWith (
571+ expect . objectContaining ( { providerConfig : { eventType : 'record.created' } } )
572+ )
573+ } )
574+
575+ /**
576+ * A re-save that omits the identity fields keeps the ones the server stored,
577+ * so an existing integration is not broken by the client never sending them.
578+ */
579+ it ( 'keeps the stored credential and server-set userId on a re-save that omits them' , async ( ) => {
580+ queueUpdatePathRows ( true , { credentialId : 'stored-credential' , userId : 'credential-owner' } )
581+
582+ const response = await POST (
583+ upsertRequest ( { userId : 'victim-user' , eventType : 'record.created' } )
584+ )
585+
586+ expect ( response . status ) . toBe ( 200 )
587+ expect ( mocks . authorizeCredentialUseForAuth ) . not . toHaveBeenCalled ( )
588+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
589+ expect . objectContaining ( {
590+ providerConfig : {
591+ eventType : 'record.created' ,
592+ credentialId : 'stored-credential' ,
593+ userId : 'credential-owner' ,
594+ } ,
595+ } )
596+ )
597+ } )
598+ } )
0 commit comments