@@ -264,6 +264,295 @@ describe('MS Bing Ads Audiences syncAudiences', () => {
264264 expect ( payloadArg [ 0 ] . email ) . toBe ( 'add1@segment.com' )
265265 } )
266266
267+ describe ( 'debug logging (actions-ms-bing-ads-audiences-debug-logging flag)' , ( ) => {
268+ const DEBUG_FLAG = 'actions-ms-bing-ads-audiences-debug-logging'
269+
270+ const makeLogger = ( ) =>
271+ ( {
272+ info : jest . fn ( ) ,
273+ error : jest . fn ( ) ,
274+ warn : jest . fn ( ) ,
275+ debug : jest . fn ( ) ,
276+ crit : jest . fn ( ) ,
277+ log : jest . fn ( ) ,
278+ withTags : jest . fn ( ) ,
279+ level : 'info' ,
280+ name : 'test'
281+ } as any )
282+
283+ const addEvent = ( ) =>
284+ createTestEvent ( {
285+ type : 'identify' ,
286+ properties : { aud_key : true } ,
287+ context : { traits : { email : 'demo@segment.com' } }
288+ } )
289+
290+ it ( 'does not log when the flag is off' , async ( ) => {
291+ nock ( BASE_URL ) . post ( '/CustomerListUserData/Apply' ) . reply ( 200 , { } )
292+ const logger = makeLogger ( )
293+
294+ await testDestination . testAction ( 'syncAudiences' , {
295+ event : addEvent ( ) ,
296+ mapping : baseMapping ,
297+ useDefaultMappings : true ,
298+ settings,
299+ logger,
300+ features : { [ DEBUG_FLAG ] : false }
301+ } )
302+
303+ expect ( logger . info ) . not . toHaveBeenCalled ( )
304+ expect ( logger . error ) . not . toHaveBeenCalled ( )
305+ } )
306+
307+ it ( 'logs response metadata when the flag is on, without leaking CustomerListItems' , async ( ) => {
308+ nock ( BASE_URL ) . post ( '/CustomerListUserData/Apply' ) . reply ( 200 , { PartialErrors : [ ] } )
309+ const logger = makeLogger ( )
310+
311+ await testDestination . testAction ( 'syncAudiences' , {
312+ event : addEvent ( ) ,
313+ mapping : baseMapping ,
314+ useDefaultMappings : true ,
315+ settings,
316+ logger,
317+ features : { [ DEBUG_FLAG ] : true }
318+ } )
319+
320+ expect ( logger . info ) . toHaveBeenCalledTimes ( 1 )
321+ const logged = ( logger . info as jest . Mock ) . mock . calls [ 0 ] [ 0 ] as string
322+ expect ( logged ) . toContain ( '[ms-bing-ads-audiences][DEBUG]' )
323+ expect ( logged ) . toContain ( 'identifierType=Email' )
324+ expect ( logged ) . toContain ( 'itemCount=1' )
325+ expect ( logged ) . toContain ( 'partialErrors=[]' )
326+ // The hashed identifier must never be logged.
327+ expect ( logged ) . not . toContain ( '5a95f052958dac8ed1d66d74eb481b3ccdbbc953b583c5ff0325be6b091d6281' )
328+ } )
329+
330+ it ( 'logs the Microsoft tracking id from the response header' , async ( ) => {
331+ nock ( BASE_URL )
332+ . post ( '/CustomerListUserData/Apply' )
333+ . reply ( 200 , { PartialErrors : [ ] } , { TrackingId : 'abc-123-track' } )
334+ const logger = makeLogger ( )
335+
336+ await testDestination . testAction ( 'syncAudiences' , {
337+ event : addEvent ( ) ,
338+ mapping : baseMapping ,
339+ useDefaultMappings : true ,
340+ settings,
341+ logger,
342+ features : { [ DEBUG_FLAG ] : true }
343+ } )
344+
345+ const logged = ( logger . info as jest . Mock ) . mock . calls [ 0 ] [ 0 ] as string
346+ expect ( logged ) . toContain ( 'trackingId=abc-123-track' )
347+ } )
348+
349+ it ( 'falls back to a body-level tracking id when no header is present' , async ( ) => {
350+ nock ( BASE_URL ) . post ( '/CustomerListUserData/Apply' ) . reply ( 200 , { TrackingId : 'body-track-789' , PartialErrors : [ ] } )
351+ const logger = makeLogger ( )
352+
353+ await testDestination . testAction ( 'syncAudiences' , {
354+ event : addEvent ( ) ,
355+ mapping : baseMapping ,
356+ useDefaultMappings : true ,
357+ settings,
358+ logger,
359+ features : { [ DEBUG_FLAG ] : true }
360+ } )
361+
362+ const logged = ( logger . info as jest . Mock ) . mock . calls [ 0 ] [ 0 ] as string
363+ expect ( logged ) . toContain ( 'trackingId=body-track-789' )
364+ } )
365+
366+ it ( 'logs the tracking id in full, never truncated' , async ( ) => {
367+ // Even an unexpectedly long tracking id must be quotable to Microsoft support verbatim.
368+ const longTrackingId = `T-${ '9' . repeat ( 6000 ) } `
369+ nock ( BASE_URL )
370+ . post ( '/CustomerListUserData/Apply' )
371+ . reply ( 200 , { PartialErrors : [ ] } , { TrackingId : longTrackingId } )
372+ const logger = makeLogger ( )
373+
374+ await testDestination . testAction ( 'syncAudiences' , {
375+ event : addEvent ( ) ,
376+ mapping : baseMapping ,
377+ useDefaultMappings : true ,
378+ settings,
379+ logger,
380+ features : { [ DEBUG_FLAG ] : true }
381+ } )
382+
383+ const logged = ( logger . info as jest . Mock ) . mock . calls [ 0 ] [ 0 ] as string
384+ expect ( logged ) . toContain ( `trackingId=${ longTrackingId } ` )
385+ expect ( logged ) . not . toContain ( '[truncated]' )
386+ } )
387+
388+ it ( 'redacts PartialError free-text fields that can echo identifiers' , async ( ) => {
389+ // Bing can echo the offending identifier in Message/Details/FieldPath. Only codes/index
390+ // should be logged, never the free-text fields.
391+ nock ( BASE_URL )
392+ . post ( '/CustomerListUserData/Apply' )
393+ . reply ( 200 , {
394+ PartialErrors : [
395+ {
396+ ErrorCode : 'InvalidCustomerListItem' ,
397+ Code : 4001 ,
398+ Index : 0 ,
399+ Type : 'BatchError' ,
400+ Message : 'Invalid value crm_secret_12345' ,
401+ Details : 'crm_secret_12345' ,
402+ FieldPath : 'CustomerListItems[0]=crm_secret_12345'
403+ }
404+ ]
405+ } )
406+ const logger = makeLogger ( )
407+
408+ await testDestination . testBatchAction ( 'syncAudiences' , {
409+ events : [ addEvent ( ) ] ,
410+ mapping : baseMapping ,
411+ useDefaultMappings : true ,
412+ settings,
413+ logger,
414+ features : { [ DEBUG_FLAG ] : true }
415+ } )
416+
417+ const logged = ( logger . info as jest . Mock ) . mock . calls [ 0 ] [ 0 ] as string
418+ expect ( logged ) . toContain ( 'ErrorCode' )
419+ expect ( logged ) . toContain ( 'InvalidCustomerListItem' )
420+ // The free-text fields (and any identifier they echo) must not be logged.
421+ expect ( logged ) . not . toContain ( 'crm_secret_12345' )
422+ expect ( logged ) . not . toContain ( 'Message' )
423+ expect ( logged ) . not . toContain ( 'FieldPath' )
424+ } )
425+
426+ it ( 'strips control characters from logged content to prevent log injection' , async ( ) => {
427+ // A crafted response body with newlines must not be able to forge log lines.
428+ nock ( BASE_URL )
429+ . post ( '/CustomerListUserData/Apply' )
430+ . reply ( 200 , {
431+ PartialErrors : [
432+ { ErrorCode : 'X\nInjected' , Code : 1 , Index : 0 , Type : 'T' , Message : null , Details : null , FieldPath : null }
433+ ]
434+ } )
435+ const logger = makeLogger ( )
436+
437+ await testDestination . testBatchAction ( 'syncAudiences' , {
438+ events : [ addEvent ( ) ] ,
439+ mapping : baseMapping ,
440+ useDefaultMappings : true ,
441+ settings,
442+ logger,
443+ features : { [ DEBUG_FLAG ] : true }
444+ } )
445+
446+ const logged = ( logger . info as jest . Mock ) . mock . calls [ 0 ] [ 0 ] as string
447+ expect ( logged ) . not . toContain ( '\n' )
448+ } )
449+
450+ it ( 'sanitizes a crafted audience id to prevent log injection' , async ( ) => {
451+ nock ( BASE_URL ) . post ( '/CustomerListUserData/Apply' ) . reply ( 200 , { PartialErrors : [ ] } )
452+ const logger = makeLogger ( )
453+
454+ await testDestination . testAction ( 'syncAudiences' , {
455+ event : addEvent ( ) ,
456+ mapping : { ...baseMapping , audience_id : 'aud\n_injected' } ,
457+ useDefaultMappings : true ,
458+ settings,
459+ logger,
460+ features : { [ DEBUG_FLAG ] : true }
461+ } )
462+
463+ const logged = ( logger . info as jest . Mock ) . mock . calls [ 0 ] [ 0 ] as string
464+ expect ( logged ) . not . toContain ( '\n' )
465+ } )
466+
467+ it ( 'truncates oversized values without exceeding the cap' , async ( ) => {
468+ // A long PartialError code forces truncation; the resulting log line's summary segment
469+ // (including the suffix) must stay within the configured cap.
470+ const longCode = 'C' . repeat ( 10000 )
471+ nock ( BASE_URL )
472+ . post ( '/CustomerListUserData/Apply' )
473+ . reply ( 200 , {
474+ PartialErrors : [
475+ { ErrorCode : longCode , Code : 1 , Index : 0 , Type : 'T' , Message : null , Details : null , FieldPath : null }
476+ ]
477+ } )
478+ const logger = makeLogger ( )
479+
480+ await testDestination . testBatchAction ( 'syncAudiences' , {
481+ events : [ addEvent ( ) ] ,
482+ mapping : baseMapping ,
483+ useDefaultMappings : true ,
484+ settings,
485+ logger,
486+ features : { [ DEBUG_FLAG ] : true }
487+ } )
488+
489+ const logged = ( logger . info as jest . Mock ) . mock . calls [ 0 ] [ 0 ] as string
490+ const partialErrors = logged . split ( 'partialErrors=' ) [ 1 ]
491+ expect ( partialErrors ) . toContain ( '[truncated]' )
492+ // The truncated segment (suffix included) must not exceed the 4096 cap.
493+ expect ( partialErrors . length ) . toBeLessThanOrEqual ( 4096 )
494+ } )
495+
496+ it ( 'does not let a throwing logger break the action' , async ( ) => {
497+ nock ( BASE_URL ) . post ( '/CustomerListUserData/Apply' ) . reply ( 200 , { PartialErrors : [ ] } )
498+ const logger = makeLogger ( )
499+ ; ( logger . info as jest . Mock ) . mockImplementation ( ( ) => {
500+ throw new Error ( 'logger exploded' )
501+ } )
502+
503+ // A throwing logger must be swallowed so the action still succeeds.
504+ const response = await testDestination . testAction ( 'syncAudiences' , {
505+ event : addEvent ( ) ,
506+ mapping : baseMapping ,
507+ useDefaultMappings : true ,
508+ settings,
509+ logger,
510+ features : { [ DEBUG_FLAG ] : true }
511+ } )
512+
513+ expect ( response [ 0 ] . status ) . toBe ( 200 )
514+ } )
515+
516+ it ( 'does not crash when the response body is empty' , async ( ) => {
517+ // Empty body => response.data is undefined; logging must still succeed.
518+ nock ( BASE_URL ) . post ( '/CustomerListUserData/Apply' ) . reply ( 200 )
519+ const logger = makeLogger ( )
520+
521+ const response = await testDestination . testAction ( 'syncAudiences' , {
522+ event : addEvent ( ) ,
523+ mapping : baseMapping ,
524+ useDefaultMappings : true ,
525+ settings,
526+ logger,
527+ features : { [ DEBUG_FLAG ] : true }
528+ } )
529+
530+ expect ( logger . info ) . toHaveBeenCalledTimes ( 1 )
531+ expect ( response [ 0 ] . status ) . toBe ( 200 )
532+ } )
533+
534+ it ( 'does not log on the error path (only success responses are logged)' , async ( ) => {
535+ nock ( BASE_URL ) . post ( '/CustomerListUserData/Apply' ) . reply ( 500 , { message : 'boom' } )
536+ const logger = makeLogger ( )
537+
538+ const response = await testDestination . testBatchAction ( 'syncAudiences' , {
539+ events : [ addEvent ( ) ] ,
540+ mapping : baseMapping ,
541+ useDefaultMappings : true ,
542+ settings,
543+ logger,
544+ features : { [ DEBUG_FLAG ] : true }
545+ } )
546+
547+ // We only log successful responses; the error path emits nothing.
548+ expect ( logger . info ) . not . toHaveBeenCalled ( )
549+ expect ( logger . error ) . not . toHaveBeenCalled ( )
550+ // The HTTP error is still handled normally.
551+ expect ( utils . handleHttpError ) . toHaveBeenCalled ( )
552+ expect ( response [ 0 ] . status ) . toBe ( 500 )
553+ } )
554+ } )
555+
267556 it ( 'should throw non-HTTP errors in batch mode' , async ( ) => {
268557 // Create a custom error that is NOT an HTTPError
269558 const customError = new Error ( 'Custom non-HTTP error' )
0 commit comments