11import appFunc from '../../src' ;
22import { FastifyInstance } from 'fastify' ;
33import { authorizeRequest , saveFixtures } from '../helpers' ;
4- import { User } from '../../src/entity' ;
4+ import { Organization , User } from '../../src/entity' ;
55import { usersFixture } from '../fixture' ;
66import { DataSource } from 'typeorm' ;
77import createOrGetConnection from '../../src/db' ;
@@ -11,11 +11,20 @@ import {
1111 UserIntegration ,
1212 UserIntegrationType ,
1313} from '../../src/entity/UserIntegration' ;
14- import { SlackEvent } from '../../src/common' ;
14+ import { SlackEvent , verifySlackSignature } from '../../src/common' ;
1515import {
1616 AnalyticsEventName ,
1717 sendAnalyticsEvent ,
1818} from '../../src/integrations/analytics' ;
19+ import { DatasetLocation } from '../../src/entity/dataset/DatasetLocation' ;
20+ import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob' ;
21+ import {
22+ datasetLocationsFixture ,
23+ opportunitiesFixture ,
24+ organizationsFixture ,
25+ } from '../fixture/opportunity' ;
26+ import { OpportunityMatchStatus } from '../../src/entity/opportunities/types' ;
27+ import { OpportunityMatch } from '../../src/entity/OpportunityMatch' ;
1928
2029jest . mock ( '../../src/integrations/analytics' , ( ) => ( {
2130 ...( jest . requireActual ( '../../src/integrations/analytics' ) as Record <
@@ -25,6 +34,20 @@ jest.mock('../../src/integrations/analytics', () => ({
2534 sendAnalyticsEvent : jest . fn ( ) ,
2635} ) ) ;
2736
37+ const actualVerifySlackSignature =
38+ jest . requireActual < typeof import ( '../../src/common' ) > (
39+ '../../src/common' ,
40+ ) . verifySlackSignature ;
41+
42+ jest . mock ( '../../src/common' , ( ) => ( {
43+ ...( jest . requireActual ( '../../src/common' ) as Record < string , unknown > ) ,
44+ verifySlackSignature : jest . fn ( ) ,
45+ } ) ) ;
46+
47+ const mockVerifySlackSignature = verifySlackSignature as jest . MockedFunction <
48+ typeof verifySlackSignature
49+ > ;
50+
2851let app : FastifyInstance ;
2952let con : DataSource ;
3053
@@ -39,6 +62,8 @@ afterAll(() => app.close());
3962beforeEach ( async ( ) => {
4063 nock . cleanAll ( ) ;
4164 jest . resetAllMocks ( ) ;
65+ // Use the actual implementation by default (for events tests)
66+ mockVerifySlackSignature . mockImplementation ( actualVerifySlackSignature ) ;
4267 await saveFixtures ( con , User , usersFixture ) ;
4368} ) ;
4469
@@ -482,3 +507,129 @@ describe('POST /integrations/slack/events', () => {
482507 expect ( await teamIntegrationsQuery . getCount ( ) ) . toBe ( 1 ) ;
483508 } ) ;
484509} ) ;
510+
511+ describe ( 'POST /integrations/slack/interactions' , ( ) => {
512+ const createInteractionPayload = (
513+ actionId : string ,
514+ opportunityId : string ,
515+ userId : string ,
516+ ) =>
517+ `payload=${ encodeURIComponent (
518+ JSON . stringify ( {
519+ type : 'block_actions' ,
520+ actions : [
521+ {
522+ action_id : actionId ,
523+ value : JSON . stringify ( { opportunityId, userId } ) ,
524+ } ,
525+ ] ,
526+ response_url : 'https://hooks.slack.com/actions/test' ,
527+ user : { id : 'U123' , username : 'testuser' } ,
528+ } ) ,
529+ ) } `;
530+
531+ beforeEach ( async ( ) => {
532+ await saveFixtures ( con , DatasetLocation , datasetLocationsFixture ) ;
533+ await saveFixtures ( con , Organization , organizationsFixture ) ;
534+ await saveFixtures ( con , OpportunityJob , opportunitiesFixture ) ;
535+ mockVerifySlackSignature . mockReturnValue ( true ) ;
536+ } ) ;
537+
538+ it ( 'should return 403 when signature is invalid' , async ( ) => {
539+ mockVerifySlackSignature . mockReturnValue ( false ) ;
540+
541+ const { body } = await request ( app . server )
542+ . post ( '/integrations/slack/interactions' )
543+ . set ( 'Content-Type' , 'application/x-www-form-urlencoded' )
544+ . send ( createInteractionPayload ( 'candidate_review_accept' , 'opp1' , 'u1' ) )
545+ . expect ( 403 ) ;
546+
547+ expect ( body ) . toEqual ( { error : 'invalid signature' } ) ;
548+ } ) ;
549+
550+ it ( 'should accept candidate and update match status' , async ( ) => {
551+ const match = {
552+ opportunityId : '550e8400-e29b-41d4-a716-446655440001' ,
553+ userId : '1' ,
554+ status : OpportunityMatchStatus . CandidateReview ,
555+ createdAt : new Date ( ) ,
556+ updatedAt : new Date ( ) ,
557+ } ;
558+ await saveFixtures ( con , OpportunityMatch , [ match ] ) ;
559+
560+ nock ( 'https://hooks.slack.com' ) . post ( '/actions/test' ) . reply ( 200 ) ;
561+
562+ await request ( app . server )
563+ . post ( '/integrations/slack/interactions' )
564+ . set ( 'Content-Type' , 'application/x-www-form-urlencoded' )
565+ . set ( 'x-slack-request-timestamp' , '1722461509' )
566+ . set (
567+ 'x-slack-signature' ,
568+ 'v0=test' , // Signature validation is mocked in test env
569+ )
570+ . send (
571+ createInteractionPayload (
572+ 'candidate_review_accept' ,
573+ match . opportunityId ,
574+ match . userId ,
575+ ) ,
576+ )
577+ . expect ( 200 ) ;
578+
579+ const updatedMatch = await con . getRepository ( OpportunityMatch ) . findOneBy ( {
580+ opportunityId : match . opportunityId ,
581+ userId : match . userId ,
582+ } ) ;
583+ expect ( updatedMatch ?. status ) . toBe ( OpportunityMatchStatus . CandidateAccepted ) ;
584+ } ) ;
585+
586+ it ( 'should reject candidate and update match status' , async ( ) => {
587+ const match = {
588+ opportunityId : '550e8400-e29b-41d4-a716-446655440001' ,
589+ userId : '1' ,
590+ status : OpportunityMatchStatus . CandidateReview ,
591+ createdAt : new Date ( ) ,
592+ updatedAt : new Date ( ) ,
593+ } ;
594+ await saveFixtures ( con , OpportunityMatch , [ match ] ) ;
595+
596+ nock ( 'https://hooks.slack.com' ) . post ( '/actions/test' ) . reply ( 200 ) ;
597+
598+ await request ( app . server )
599+ . post ( '/integrations/slack/interactions' )
600+ . set ( 'Content-Type' , 'application/x-www-form-urlencoded' )
601+ . set ( 'x-slack-request-timestamp' , '1722461509' )
602+ . set ( 'x-slack-signature' , 'v0=test' )
603+ . send (
604+ createInteractionPayload (
605+ 'candidate_review_reject' ,
606+ match . opportunityId ,
607+ match . userId ,
608+ ) ,
609+ )
610+ . expect ( 200 ) ;
611+
612+ const updatedMatch = await con . getRepository ( OpportunityMatch ) . findOneBy ( {
613+ opportunityId : match . opportunityId ,
614+ userId : match . userId ,
615+ } ) ;
616+ expect ( updatedMatch ?. status ) . toBe ( OpportunityMatchStatus . RecruiterRejected ) ;
617+ } ) ;
618+
619+ it ( 'should return 200 for unknown action types' , async ( ) => {
620+ await request ( app . server )
621+ . post ( '/integrations/slack/interactions' )
622+ . set ( 'Content-Type' , 'application/x-www-form-urlencoded' )
623+ . set ( 'x-slack-request-timestamp' , '1722461509' )
624+ . set ( 'x-slack-signature' , 'v0=test' )
625+ . send (
626+ `payload=${ encodeURIComponent (
627+ JSON . stringify ( {
628+ type : 'block_actions' ,
629+ actions : [ { action_id : 'unknown_action' , value : '{}' } ] ,
630+ } ) ,
631+ ) } `,
632+ )
633+ . expect ( 200 ) ;
634+ } ) ;
635+ } ) ;
0 commit comments