Skip to content

Commit 013f551

Browse files
authored
Merge pull request #6194 from mozilla/fix/email-breach-alerts
fix(breach-alerts): update cron logic for failure cases
2 parents eed2d7f + 387d9ec commit 013f551

File tree

2 files changed

+162
-103
lines changed

2 files changed

+162
-103
lines changed

src/scripts/cronjobs/emailBreachAlerts.test.ts

Lines changed: 115 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { test, expect, jest } from "@jest/globals";
6+
import { setTimeout } from "timers/promises";
67

78
process.env.GCP_PUBSUB_PROJECT_ID = "arbitrary-id";
89
process.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

Comments
 (0)