11import { describe , expect , test } from "bun:test" ;
2+ import type { Agent } from "@blink.so/database/schema" ;
23import type { Server } from "bun" ;
34import { serve } from "../test" ;
45
56interface SetupAgentOptions {
67 name : string ;
7- handler : ( req : Request ) => Response ;
8+ handler : ( req : Request ) => Response | Promise < Response > ;
9+ /** If provided, sets up slack_verification on the agent */
10+ slackVerification ?: {
11+ signingSecret : string ;
12+ botToken : string ;
13+ /** Custom expiresAt timestamp (defaults to 24h from now) */
14+ expiresAt ?: string ;
15+ } ;
816}
917
1018interface SetupAgentResult extends Disposable {
19+ /** The agent ID */
20+ agentId : string ;
1121 /** Subpath webhook URL (/api/webhook/:id) - cookies are stripped */
1222 webhookUrl : string ;
1323 getWebhookUrl : ( subpath ?: string ) => string ;
1424 /** Fetch via subdomain (uses Host header) - cookies pass through */
1525 fetchSubdomain : ( path ?: string , init ?: RequestInit ) => Promise < Response > ;
26+ /** Get the current agent from the database */
27+ getAgent : ( ) => Promise < Agent | undefined > ;
1628}
1729
1830async function setupAgent (
@@ -74,6 +86,24 @@ async function setupAgent(
7486 if ( ! agent . request_url ) throw new Error ( "No webhook route" ) ;
7587
7688 const db = await bindings . database ( ) ;
89+
90+ // Set up slack verification if provided
91+ if ( options . slackVerification ) {
92+ const now = new Date ( ) ;
93+ const defaultExpiresAt = new Date (
94+ now . getTime ( ) + 24 * 60 * 60 * 1000
95+ ) . toISOString ( ) ;
96+ await db . updateAgent ( {
97+ id : agent . id ,
98+ slack_verification : {
99+ signingSecret : options . slackVerification . signingSecret ,
100+ botToken : options . slackVerification . botToken ,
101+ startedAt : now . toISOString ( ) ,
102+ expiresAt : options . slackVerification . expiresAt ?? defaultExpiresAt ,
103+ } ,
104+ } ) ;
105+ }
106+
77107 const target = await db . selectAgentDeploymentTargetByName (
78108 agent . id ,
79109 "production"
@@ -85,6 +115,7 @@ async function setupAgent(
85115 const subdomainHost = `${ requestId } .${ parsedApiUrl . host } ` ;
86116
87117 return {
118+ agentId : agent . id ,
88119 webhookUrl : `${ apiUrl } /api/webhook/${ target . request_id } ` ,
89120 getWebhookUrl : ( subpath ?: string ) =>
90121 `${ apiUrl } /api/webhook/${ target . request_id } ${ subpath || "" } ` ,
@@ -95,6 +126,7 @@ async function setupAgent(
95126 headers . set ( "host" , subdomainHost ) ;
96127 return fetch ( `${ apiUrl } ${ path || "/" } ` , { ...init , headers } ) ;
97128 } ,
129+ getAgent : ( ) => db . selectAgentByID ( agent . id ) ,
98130 [ Symbol . dispose ] : ( ) => mockServer ?. stop ( ) ,
99131 } ;
100132}
@@ -378,6 +410,57 @@ describe("webhook requests (/api/webhook/:id)", () => {
378410 } ) ;
379411} ) ;
380412
413+ describe ( "Slack request Content-Length handling" , ( ) => {
414+ test ( "recalculates Content-Length when body is read as text for Slack verification" , async ( ) => {
415+ // Body with multi-byte UTF-8 characters
416+ // "Hello 世界" is 6 ASCII chars (6 bytes) + 1 space (1 byte) + 2 Chinese chars (6 bytes) = 13 bytes
417+ const bodyWithMultiByteChars = JSON . stringify ( {
418+ type : "event_callback" ,
419+ event : { type : "message" , text : "Hello 世界" } ,
420+ } ) ;
421+ const expectedByteLength = new TextEncoder ( ) . encode (
422+ bodyWithMultiByteChars
423+ ) . length ;
424+
425+ let receivedContentLength : string | null | undefined ;
426+ let receivedBodyLength : number | undefined ;
427+
428+ using agent = await setupAgent ( {
429+ name : "slack-content-length" ,
430+ handler : async ( req ) => {
431+ receivedContentLength = req . headers . get ( "content-length" ) ;
432+ const body = await req . text ( ) ;
433+ receivedBodyLength = new TextEncoder ( ) . encode ( body ) . length ;
434+ return new Response ( "OK" ) ;
435+ } ,
436+ slackVerification : {
437+ signingSecret : "test-secret" ,
438+ botToken : "xoxb-test-token" ,
439+ } ,
440+ } ) ;
441+
442+ // Send request with a Content-Length that would be wrong if we used string length
443+ // instead of byte length (string length is different from byte length for multi-byte chars)
444+ const response = await fetch ( agent . getWebhookUrl ( "/slack" ) , {
445+ method : "POST" ,
446+ headers : {
447+ "content-type" : "application/json" ,
448+ // Intentionally set a wrong Content-Length to verify it gets corrected
449+ "content-length" : String ( bodyWithMultiByteChars . length ) ,
450+ } ,
451+ body : bodyWithMultiByteChars ,
452+ } ) ;
453+
454+ // The request should succeed (we won't have valid Slack signature, but that's OK for this test)
455+ // What we're testing is that the Content-Length was recalculated correctly
456+ expect ( response . status ) . toBe ( 200 ) ;
457+
458+ // Verify the Content-Length header received by the agent matches actual byte length
459+ expect ( receivedContentLength ) . toBe ( String ( expectedByteLength ) ) ;
460+ expect ( receivedBodyLength ) . toBe ( expectedByteLength ) ;
461+ } ) ;
462+ } ) ;
463+
381464describe ( "subdomain requests" , ( ) => {
382465 test ( "basic request" , async ( ) => {
383466 using agent = await setupAgent ( {
@@ -474,3 +557,110 @@ describe("subdomain requests", () => {
474557 expect ( response . headers . get ( "vary" ) ) . toBe ( "Origin, Accept-Encoding" ) ;
475558 } ) ;
476559} ) ;
560+
561+ describe ( "Slack verification expiration" , ( ) => {
562+ test ( "clears expired slack_verification and skips verification processing" , async ( ) => {
563+ // Set expiresAt to 1 hour ago (already expired)
564+ const expiredAt = new Date ( Date . now ( ) - 1 * 60 * 60 * 1000 ) . toISOString ( ) ;
565+
566+ let handlerCalled = false ;
567+
568+ using agent = await setupAgent ( {
569+ name : "slack-expired" ,
570+ handler : ( ) => {
571+ handlerCalled = true ;
572+ return new Response ( "OK" ) ;
573+ } ,
574+ slackVerification : {
575+ signingSecret : "test-secret" ,
576+ botToken : "xoxb-test-token" ,
577+ expiresAt : expiredAt ,
578+ } ,
579+ } ) ;
580+
581+ // Verify slack_verification is set before request
582+ const beforeAgent = await agent . getAgent ( ) ;
583+ expect ( beforeAgent ?. slack_verification ) . not . toBeNull ( ) ;
584+
585+ // Make a request to /slack path
586+ const response = await fetch ( agent . getWebhookUrl ( "/slack" ) , {
587+ method : "POST" ,
588+ headers : { "content-type" : "application/json" } ,
589+ body : JSON . stringify ( { type : "event_callback" , event : { type : "test" } } ) ,
590+ } ) ;
591+
592+ expect ( response . status ) . toBe ( 200 ) ;
593+ expect ( handlerCalled ) . toBe ( true ) ;
594+
595+ // Verify slack_verification was cleared
596+ const afterAgent = await agent . getAgent ( ) ;
597+ expect ( afterAgent ?. slack_verification ) . toBeNull ( ) ;
598+ } ) ;
599+
600+ test ( "does not clear non-expired slack_verification" , async ( ) => {
601+ // Set expiresAt to 1 hour from now (not expired yet)
602+ const futureExpiresAt = new Date (
603+ Date . now ( ) + 1 * 60 * 60 * 1000
604+ ) . toISOString ( ) ;
605+
606+ using agent = await setupAgent ( {
607+ name : "slack-not-expired" ,
608+ handler : ( ) => new Response ( "OK" ) ,
609+ slackVerification : {
610+ signingSecret : "test-secret" ,
611+ botToken : "xoxb-test-token" ,
612+ expiresAt : futureExpiresAt ,
613+ } ,
614+ } ) ;
615+
616+ // Verify slack_verification is set before request
617+ const beforeAgent = await agent . getAgent ( ) ;
618+ expect ( beforeAgent ?. slack_verification ) . not . toBeNull ( ) ;
619+
620+ // Make a request to /slack path
621+ const response = await fetch ( agent . getWebhookUrl ( "/slack" ) , {
622+ method : "POST" ,
623+ headers : { "content-type" : "application/json" } ,
624+ body : JSON . stringify ( { type : "event_callback" , event : { type : "test" } } ) ,
625+ } ) ;
626+
627+ expect ( response . status ) . toBe ( 200 ) ;
628+
629+ // Verify slack_verification was NOT cleared (still active)
630+ const afterAgent = await agent . getAgent ( ) ;
631+ expect ( afterAgent ?. slack_verification ) . not . toBeNull ( ) ;
632+ } ) ;
633+
634+ test ( "does not run verification logic for expired slack_verification on non-slack paths" , async ( ) => {
635+ // Set expiresAt to 1 hour ago (already expired)
636+ const expiredAt = new Date ( Date . now ( ) - 1 * 60 * 60 * 1000 ) . toISOString ( ) ;
637+
638+ using agent = await setupAgent ( {
639+ name : "slack-expired-non-slack-path" ,
640+ handler : ( ) => new Response ( "OK" ) ,
641+ slackVerification : {
642+ signingSecret : "test-secret" ,
643+ botToken : "xoxb-test-token" ,
644+ expiresAt : expiredAt ,
645+ } ,
646+ } ) ;
647+
648+ // Verify slack_verification is set before request
649+ const beforeAgent = await agent . getAgent ( ) ;
650+ expect ( beforeAgent ?. slack_verification ) . not . toBeNull ( ) ;
651+
652+ // Make a request to a non-slack path (e.g., /github)
653+ const response = await fetch ( agent . getWebhookUrl ( "/github" ) , {
654+ method : "POST" ,
655+ headers : { "content-type" : "application/json" } ,
656+ body : JSON . stringify ( { action : "push" } ) ,
657+ } ) ;
658+
659+ expect ( response . status ) . toBe ( 200 ) ;
660+
661+ // slack_verification should NOT be cleared for non-slack paths
662+ // (expiration check only runs for /slack requests)
663+ const afterAgent = await agent . getAgent ( ) ;
664+ expect ( afterAgent ?. slack_verification ) . not . toBeNull ( ) ;
665+ } ) ;
666+ } ) ;
0 commit comments