33 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
55import { test , expect , jest } from "@jest/globals" ;
6+ import { setTimeout } from "timers/promises" ;
67
78process . env . GCP_PUBSUB_PROJECT_ID = "arbitrary-id" ;
89process . env . GCP_PUBSUB_SUBSCRIPTION_NAME = "arbitrary-name" ;
@@ -11,6 +12,8 @@ jest.mock("@sentry/nextjs", () => {
1112 return {
1213 init : jest . fn ( ) ,
1314 captureCheckIn : jest . fn ( ) ,
15+ captureException : jest . fn ( ) ,
16+ captureMessage : jest . fn ( ) ,
1417 } ;
1518} ) ;
1619
@@ -80,6 +83,12 @@ jest.mock("../../app/functions/server/logging", () => {
8083 info ( message : string , details : object ) {
8184 console . info ( message , details ) ;
8285 }
86+ error ( message : string , details : object ) {
87+ console . error ( message , details ) ;
88+ }
89+ debug ( message : string , details : object ) {
90+ console . debug ( message , details ) ;
91+ }
8392 }
8493
8594 const logger = new Logging ( ) ;
@@ -432,7 +441,7 @@ test("skipping email when subscriber id exists in email_notifications table", as
432441 ) ;
433442} ) ;
434443
435- test ( "throws an error when addEmailNotification fails" , async ( ) => {
444+ test ( "Does not attempt to send an email if addEmailNotification fails and does not acknowledge message " , async ( ) => {
436445 const consoleLog = jest
437446 . spyOn ( console , "log" )
438447 . mockImplementation ( ( ) => undefined ) ;
@@ -459,7 +468,7 @@ test("throws an error when addEmailNotification fails", async () => {
459468
460469 jest . mock ( "../../db/tables/emailAddresses" , ( ) => {
461470 return {
462- getEmailAddressesByHashes : jest . fn ( ( ) => [ "" ] ) ,
471+ getEmailAddressesByHashes : jest . fn ( ( ) => [ ] ) ,
463472 } ;
464473 } ) ;
465474
@@ -479,61 +488,56 @@ test("throws an error when addEmailNotification fails", async () => {
479488
480489 const { poll } = await import ( "./emailBreachAlerts" ) ;
481490
482- try {
483- await poll ( subClient , receivedMessages ) ;
484- } catch ( e : unknown ) {
485- // eslint-disable-next-line jest/no-conditional-expect
486- expect ( console . error ) . toHaveBeenCalled ( ) ;
487- // eslint-disable-next-line jest/no-conditional-expect
488- expect ( ( e as Error ) . message ) . toBe ( "add failed" ) ;
489- }
491+ await poll ( subClient , receivedMessages ) ;
490492
491493 expect ( consoleLog ) . toHaveBeenCalledWith (
492494 'Received message: {"breachName":"test1","hashPrefix":"test-prefix1","hashSuffixes":["test-suffix1"]}' ,
493495 ) ;
494496 expect ( sendEmail ) . toHaveBeenCalledTimes ( 0 ) ;
497+ expect ( subClient . acknowledge ) . not . toHaveBeenCalled ( ) ;
495498} ) ;
496499
497- test ( "throws an error when markEmailAsNotified fails " , async ( ) => {
500+ test ( "processes valid messages for non-US users " , async ( ) => {
498501 const consoleLog = jest
499502 . spyOn ( console , "log" )
500503 . mockImplementation ( ( ) => undefined ) ;
501504 // It's not clear if the calls to console.info are important enough to remain,
502505 // but since they were already there when adding the "no logs" rule in tests,
503506 // I'm respecting Chesterton's Fence and leaving them in place for now:
504507 jest . spyOn ( console , "info" ) . mockImplementation ( ( ) => undefined ) ;
505- const { sendEmail } = await import ( "../../utils/email" ) ;
508+ const emailMod = await import ( "../../utils/email" ) ;
509+ const sendEmail = emailMod . sendEmail as jest . Mock <
510+ ( typeof emailMod ) [ "sendEmail" ]
511+ > ;
512+
506513 // eslint-disable-next-line @typescript-eslint/no-explicit-any
507514 const mockedUtilsHibp : any = jest . requireMock ( "../../utils/hibp" ) ;
508515 mockedUtilsHibp . getBreachByName . mockReturnValue ( {
509516 IsVerified : true ,
510517 Domain : "test1" ,
511518 IsFabricated : false ,
512519 IsSpamList : false ,
513- Id : 1 ,
514520 } ) ;
515521
516522 jest . mock ( "../../db/tables/subscribers" , ( ) => {
517523 return {
518- getSubscribersByHashes : jest . fn ( ( ) => [ { id : 1 } ] ) ,
519- } ;
520- } ) ;
521-
522- jest . mock ( "../../db/tables/emailAddresses" , ( ) => {
523- return {
524- getEmailAddressesByHashes : jest . fn ( ( ) => [ "" ] ) ,
524+ getSubscribersByHashes : jest . fn ( ( ) => [
525+ {
526+ onerep_profile_id : undefined ,
527+ fxa_profile_json : { locale : "nl-NL" , subscriptions : [ ] } ,
528+ } ,
529+ ] ) ,
525530 } ;
526531 } ) ;
527532
528533 jest . mock ( "../../db/tables/email_notifications" , ( ) => {
529534 return {
530- getNotifiedSubscribersForBreach : jest . fn ( ( ) => [ 2 ] ) ,
535+ getNotifiedSubscribersForBreach : jest . fn ( ( ) => [ ] ) ,
531536 addEmailNotification : jest . fn ( ) ,
532- markEmailAsNotified : jest . fn ( ) . mockImplementationOnce ( ( ) => {
533- throw new Error ( "mark failed" ) ;
534- } ) ,
537+ markEmailAsNotified : jest . fn ( ) ,
535538 } ;
536539 } ) ;
540+
537541 const receivedMessages = buildReceivedMessages ( {
538542 breachName : "test1" ,
539543 hashPrefix : "test-prefix1" ,
@@ -542,53 +546,107 @@ test("throws an error when markEmailAsNotified fails", async () => {
542546
543547 const { poll } = await import ( "./emailBreachAlerts" ) ;
544548
545- try {
546- await poll ( subClient , receivedMessages ) ;
547- } catch ( e : unknown ) {
548- // eslint-disable-next-line jest/no-conditional-expect
549- expect ( console . error ) . toHaveBeenCalled ( ) ;
550- // eslint-disable-next-line jest/no-conditional-expect
551- expect ( ( e as Error ) . message ) . toBe ( "mark failed" ) ;
552- }
549+ await poll ( subClient , receivedMessages ) ;
550+
551+ expect ( subClient . acknowledge ) . toHaveBeenCalledTimes ( 1 ) ;
552+ expect ( sendEmail ) . toHaveBeenCalledTimes ( 1 ) ;
553+
554+ // OneRep wasn't called:
555+ const { getScanResultsWithBroker } = await import (
556+ "../../db/tables/onerep_scans"
557+ ) ;
558+ expect ( getScanResultsWithBroker ) . not . toHaveBeenCalled ( ) ;
559+
553560 expect ( consoleLog ) . toHaveBeenCalledWith (
554561 'Received message: {"breachName":"test1","hashPrefix":"test-prefix1","hashSuffixes":["test-suffix1"]}' ,
555562 ) ;
556- expect ( sendEmail ) . toHaveBeenCalledTimes ( 1 ) ;
557563} ) ;
558-
559- test ( "processes valid messages for non-US users" , async ( ) => {
560- const consoleLog = jest
561- . spyOn ( console , "log" )
562- . mockImplementation ( ( ) => undefined ) ;
563- // It's not clear if the calls to console.info are important enough to remain,
564- // but since they were already there when adding the "no logs" rule in tests,
565- // I'm respecting Chesterton's Fence and leaving them in place for now:
564+ test ( "does not mark as notified if email failed to send" , async ( ) => {
565+ // Silence logs
566+ jest . spyOn ( console , "log" ) . mockImplementation ( ( ) => undefined ) ;
566567 jest . spyOn ( console , "info" ) . mockImplementation ( ( ) => undefined ) ;
568+ jest . spyOn ( console , "error" ) . mockImplementation ( ( ) => undefined ) ;
569+ // force sendEmail failure
567570 const emailMod = await import ( "../../utils/email" ) ;
568- const sendEmail = emailMod . sendEmail as jest . Mock <
569- ( typeof emailMod ) [ "sendEmail" ]
570- > ;
571+ const sendEmail = emailMod . sendEmail as jest . Mock ;
572+ sendEmail . mockImplementationOnce ( async ( ) => {
573+ throw new Error ( "send failed" ) ;
574+ } ) ;
571575
576+ // Seed a valid breach
572577 // eslint-disable-next-line @typescript-eslint/no-explicit-any
573578 const mockedUtilsHibp : any = jest . requireMock ( "../../utils/hibp" ) ;
574579 mockedUtilsHibp . getBreachByName . mockReturnValue ( {
575580 IsVerified : true ,
576581 Domain : "test1" ,
577582 IsFabricated : false ,
578583 IsSpamList : false ,
584+ Id : 1 ,
579585 } ) ;
580586
587+ // Seed subscriber and additional spies
581588 jest . mock ( "../../db/tables/subscribers" , ( ) => {
589+ return { getSubscribersByHashes : jest . fn ( ( ) => [ { id : 1 } ] ) } ;
590+ } ) ;
591+ jest . mock ( "../../db/tables/emailAddresses" , ( ) => {
592+ return { getEmailAddressesByHashes : jest . fn ( ( ) => [ ] ) } ;
593+ } ) ;
594+ jest . mock ( "../../db/tables/email_notifications" , ( ) => {
582595 return {
583- getSubscribersByHashes : jest . fn ( ( ) => [
584- {
585- onerep_profile_id : undefined ,
586- fxa_profile_json : { locale : "nl-NL" , subscriptions : [ ] } ,
587- } ,
588- ] ) ,
596+ getNotifiedSubscribersForBreach : jest . fn ( ( ) => [ ] ) ,
597+ addEmailNotification : jest . fn ( ) ,
598+ markEmailAsNotified : jest . fn ( ) ,
589599 } ;
590600 } ) ;
591601
602+ const receivedMessages = buildReceivedMessages ( {
603+ breachName : "test1" ,
604+ hashPrefix : "test-prefix1" ,
605+ hashSuffixes : [ "test-suffix1" ] ,
606+ } ) ;
607+
608+ const { poll } = await import ( "./emailBreachAlerts" ) ;
609+ const notifMod = await import ( "../../db/tables/email_notifications" ) ;
610+ const markEmailAsNotified = notifMod . markEmailAsNotified as jest . Mock ;
611+ // Run
612+ await poll ( subClient , receivedMessages ) ;
613+
614+ // Small await to reproduce the original issue
615+ await setTimeout ( 100 ) ;
616+ expect ( markEmailAsNotified ) . not . toHaveBeenCalled ( ) ;
617+ } ) ;
618+
619+ test ( "partial success: does not acknowledge message if an error occurred during notify process, but marks successful as notified" , async ( ) => {
620+ // Silence logs
621+ jest . spyOn ( console , "log" ) . mockImplementation ( ( ) => undefined ) ;
622+ jest . spyOn ( console , "info" ) . mockImplementation ( ( ) => undefined ) ;
623+ jest . spyOn ( console , "error" ) . mockImplementation ( ( ) => undefined ) ;
624+ // force sendEmail failure
625+ const emailMod = await import ( "../../utils/email" ) ;
626+ const sendEmail = emailMod . sendEmail as jest . Mock ;
627+ // Unsuccessful once only
628+ sendEmail . mockImplementationOnce ( async ( ) => {
629+ throw new Error ( "send failed" ) ;
630+ } ) ;
631+
632+ // Seed a valid breach
633+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
634+ const mockedUtilsHibp : any = jest . requireMock ( "../../utils/hibp" ) ;
635+ mockedUtilsHibp . getBreachByName . mockReturnValue ( {
636+ IsVerified : true ,
637+ Domain : "test1" ,
638+ IsFabricated : false ,
639+ IsSpamList : false ,
640+ Id : 1 ,
641+ } ) ;
642+
643+ // Seed subscriber and additional spies
644+ jest . mock ( "../../db/tables/subscribers" , ( ) => {
645+ return { getSubscribersByHashes : jest . fn ( ( ) => [ { id : 1 } ] ) } ;
646+ } ) ;
647+ jest . mock ( "../../db/tables/emailAddresses" , ( ) => {
648+ return { getEmailAddressesByHashes :
jest . fn ( ( ) => [ "[email protected] " ] ) } ; 649+ } ) ;
592650 jest . mock ( "../../db/tables/email_notifications" , ( ) => {
593651 return {
594652 getNotifiedSubscribersForBreach : jest . fn ( ( ) => [ ] ) ,
@@ -604,19 +662,13 @@ test("processes valid messages for non-US users", async () => {
604662 } ) ;
605663
606664 const { poll } = await import ( "./emailBreachAlerts" ) ;
607-
665+ const notifMod = await import ( "../../db/tables/email_notifications" ) ;
666+ const markEmailAsNotified = notifMod . markEmailAsNotified as jest . Mock ;
667+ // Run
608668 await poll ( subClient , receivedMessages ) ;
609669
610- expect ( subClient . acknowledge ) . toHaveBeenCalledTimes ( 1 ) ;
611- expect ( sendEmail ) . toHaveBeenCalledTimes ( 1 ) ;
612-
613- // OneRep wasn't called:
614- const { getScanResultsWithBroker } = await import (
615- "../../db/tables/onerep_scans"
616- ) ;
617- expect ( getScanResultsWithBroker ) . not . toHaveBeenCalled ( ) ;
618-
619- expect ( consoleLog ) . toHaveBeenCalledWith (
620- 'Received message: {"breachName":"test1","hashPrefix":"test-prefix1","hashSuffixes":["test-suffix1"]}' ,
621- ) ;
670+ // Small await to reproduce the original issue
671+ await setTimeout ( 100 ) ;
672+ expect ( markEmailAsNotified ) . toHaveBeenCalledTimes ( 1 ) ;
673+ expect ( subClient . acknowledge ) . not . toHaveBeenCalled ( ) ;
622674} ) ;
0 commit comments