@@ -10,12 +10,81 @@ import {
1010 type EnvironmentLookup ,
1111 extractSlackBotSources ,
1212 groupSlackSourcesByWorkflowCredentials ,
13+ isTransientDatabaseError ,
1314 planLegacySlackTriggerLink ,
1415 resolveSlackSourceSecrets ,
16+ retryTransientDatabaseRead ,
1517 type SlackBotSource ,
1618 type SlackMigrationBlock ,
1719} from './migrate-slack-custom-bots'
1820
21+ describe ( 'database read retries' , ( ) => {
22+ it ( 'recognizes wrapped connection errors without retrying data errors' , ( ) => {
23+ const connectionError = new Error ( 'Failed query' , {
24+ cause : Object . assign ( new Error ( 'read ECONNRESET' ) , { code : 'ECONNRESET' } ) ,
25+ } )
26+ const dataError = Object . assign ( new Error ( 'unique violation' ) , { code : '23505' } )
27+
28+ expect ( isTransientDatabaseError ( connectionError ) ) . toBe ( true )
29+ expect (
30+ isTransientDatabaseError ( Object . assign ( new Error ( 'connection failure' ) , { code : '08006' } ) )
31+ ) . toBe ( true )
32+ expect ( isTransientDatabaseError ( dataError ) ) . toBe ( false )
33+ } )
34+
35+ it ( 'retries a transient read and returns the successful result' , async ( ) => {
36+ let attempts = 0
37+ const result = await retryTransientDatabaseRead (
38+ async ( ) => {
39+ attempts ++
40+ if ( attempts < 3 ) {
41+ throw Object . assign ( new Error ( 'connection closed' ) , { code : 'CONNECTION_CLOSED' } )
42+ }
43+ return 'ok'
44+ } ,
45+ { operation : 'test read' } ,
46+ { maxAttempts : 3 , backoff : { baseMs : 1 , maxMs : 1 } }
47+ )
48+
49+ expect ( result ) . toBe ( 'ok' )
50+ expect ( attempts ) . toBe ( 3 )
51+ } )
52+
53+ it ( 'fails immediately for a non-transient read error' , async ( ) => {
54+ let attempts = 0
55+ const error = Object . assign ( new Error ( 'invalid data' ) , { code : '23505' } )
56+
57+ await expect (
58+ retryTransientDatabaseRead (
59+ async ( ) => {
60+ attempts ++
61+ throw error
62+ } ,
63+ { operation : 'test read' } ,
64+ { maxAttempts : 5 , backoff : { baseMs : 1 , maxMs : 1 } }
65+ )
66+ ) . rejects . toBe ( error )
67+ expect ( attempts ) . toBe ( 1 )
68+ } )
69+
70+ it ( 'stops after the configured number of transient attempts' , async ( ) => {
71+ let attempts = 0
72+ const error = Object . assign ( new Error ( 'connection closed' ) , { code : 'CONNECTION_CLOSED' } )
73+
74+ await expect (
75+ retryTransientDatabaseRead (
76+ async ( ) => {
77+ attempts ++
78+ throw error
79+ } ,
80+ { operation : 'test read' } ,
81+ { maxAttempts : 3 , backoff : { baseMs : 1 , maxMs : 1 } }
82+ )
83+ ) . rejects . toBe ( error )
84+ expect ( attempts ) . toBe ( 3 )
85+ } )
86+ } )
87+
1988function storedSubBlocks ( values : Record < string , unknown > ) : Record < string , { value : unknown } > {
2089 return Object . fromEntries ( Object . entries ( values ) . map ( ( [ id , value ] ) => [ id , { value } ] ) )
2190}
@@ -168,6 +237,58 @@ describe('extractSlackBotSources', () => {
168237 ] )
169238 } )
170239
240+ it ( 'ignores Slack tools without params while extracting valid sibling tools' , ( ) => {
241+ const result = extractSlackBotSources (
242+ migrationBlock ( {
243+ blockType : 'agent' ,
244+ subBlocks : storedSubBlocks ( {
245+ tools : [
246+ { type : 'slack' , title : 'Incomplete Slack tool' } ,
247+ {
248+ type : 'slack' ,
249+ title : 'Send to incidents' ,
250+ params : { authMethod : 'bot_token' , botToken : 'xoxb-tool' } ,
251+ } ,
252+ ] ,
253+ } ) ,
254+ } )
255+ )
256+
257+ expect ( result ) . toEqual ( [
258+ expect . objectContaining ( {
259+ sourceId : 'workflow-1:block-1:tools:1' ,
260+ toolTitle : 'Send to incidents' ,
261+ rawBotToken : 'xoxb-tool' ,
262+ } ) ,
263+ ] )
264+ } )
265+
266+ it ( 'ignores non-object tool entries while preserving valid sibling indexes' , ( ) => {
267+ const result = extractSlackBotSources (
268+ migrationBlock ( {
269+ blockType : 'agent' ,
270+ subBlocks : storedSubBlocks ( {
271+ tools : [
272+ 'legacy-invalid-tool' ,
273+ {
274+ type : 'slack' ,
275+ title : 'Send to incidents' ,
276+ params : { authMethod : 'bot_token' , botToken : 'xoxb-tool' } ,
277+ } ,
278+ ] ,
279+ } ) ,
280+ } )
281+ )
282+
283+ expect ( result ) . toEqual ( [
284+ expect . objectContaining ( {
285+ sourceId : 'workflow-1:block-1:tools:1' ,
286+ toolTitle : 'Send to incidents' ,
287+ rawBotToken : 'xoxb-tool' ,
288+ } ) ,
289+ ] )
290+ } )
291+
171292 it ( 'fails fast on malformed tool-input storage' , ( ) => {
172293 expect ( ( ) =>
173294 extractSlackBotSources (
@@ -374,6 +495,20 @@ describe('planLegacySlackTriggerLink', () => {
374495 } )
375496 } )
376497
498+ it ( 'marks historical Slack webhooks that predate the trigger id' , ( ) => {
499+ expect (
500+ planLegacySlackTriggerLink ( triggerSource , existingCredential , [
501+ {
502+ id : 'webhook-1' ,
503+ workflowId : 'workflow-1' ,
504+ blockId : 'block-1' ,
505+ routingKey : null ,
506+ providerConfig : { signingSecret : 'secret' } ,
507+ } ,
508+ ] )
509+ ) . toEqual ( { updateTriggerBlock : true , webhookIdsToUpdate : [ 'webhook-1' ] } )
510+ } )
511+
377512 it ( 'is idempotent after the block and webhook are linked' , ( ) => {
378513 expect (
379514 planLegacySlackTriggerLink (
@@ -406,6 +541,20 @@ describe('planLegacySlackTriggerLink', () => {
406541 )
407542 ) . toThrow ( / d i f f e r e n t S l a c k b o t c r e d e n t i a l / )
408543 } )
544+
545+ it ( 'fails fast instead of relabeling a different Slack trigger' , ( ) => {
546+ expect ( ( ) =>
547+ planLegacySlackTriggerLink ( triggerSource , existingCredential , [
548+ {
549+ id : 'webhook-1' ,
550+ workflowId : 'workflow-1' ,
551+ blockId : 'block-1' ,
552+ routingKey : null ,
553+ providerConfig : { triggerId : 'slack_oauth' } ,
554+ } ,
555+ ] )
556+ ) . toThrow ( / d o e s n o t u s e t r i g g e r s l a c k _ w e b h o o k / )
557+ } )
409558} )
410559
411560describe ( 'resolveSlackSourceSecrets' , ( ) => {
@@ -451,14 +600,25 @@ describe('resolveSlackSourceSecrets', () => {
451600 } )
452601 } )
453602
454- it ( 'still fails fast when a personal variable cannot be promoted safely' , ( ) => {
455- expect ( ( ) =>
603+ it ( 'skips a personal variable that cannot be promoted safely' , ( ) => {
604+ expect (
456605 resolveSlackSourceSecrets (
457- source ( { workflowUserId : 'user-2' , rawBotToken : '{{SLACK_BOT_TOKEN}}' } ) ,
606+ source ( {
607+ sourceId : 'workflow-1:block-1:trigger' ,
608+ kind : 'trigger' ,
609+ workflowUserId : 'user-2' ,
610+ rawSigningSecret : '{{SLACK_CASINO_SECRET}}' ,
611+ } ) ,
458612 environmentLookup ( {
459- personalVariablesByUserId : new Map ( [ [ 'user-2' , { SLACK_BOT_TOKEN : 'encrypted-value' } ] ] ) ,
613+ personalVariablesByUserId : new Map ( [
614+ [ 'user-2' , { SLACK_CASINO_SECRET : 'encrypted-value' } ] ,
615+ ] ) ,
460616 } )
461617 )
462- ) . toThrow ( / n o n - o w n e r p e r s o n a l e n v i r o n m e n t v a r i a b l e / )
618+ ) . toEqual ( {
619+ status : 'unresolved' ,
620+ reason :
621+ 'signingSecret uses non-owner personal environment variable SLACK_CASINO_SECRET; refusing to promote it to a workspace credential' ,
622+ } )
463623 } )
464624} )
0 commit comments