Skip to content

Commit d71fea3

Browse files
authored
Merge branch 'main' into mntor-5033
2 parents 8512949 + 06f69a1 commit d71fea3

File tree

3 files changed

+187
-103
lines changed

3 files changed

+187
-103
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ curl -d '{ "breachName": "000webhost", "hashPrefix": "test", "hashSuffixes": ["t
212212
This emulates HIBP notifying our API that a new breach was found. Our API will
213213
then add it to the (emulated) pubsub queue.
214214

215+
You can also use this request with staging credentials and endpoint to manually trigger alerts in the staging environment. For instructions on how to generate the hashPrefix and hashSuffix values, see [instructions below](#testing-the-breach-alerts-cron-job-locally).
216+
215217
### This pubsub queue will be consumed by this cron job, which is responsible for looking up and emailing impacted users
216218

217219
```sh
@@ -281,6 +283,29 @@ environment variable (default 30s).
281283
You can also enforce the alert being sent for a specific email address via the
282284
`LOADTEST_BREACHED_EMAIL` environment variable.
283285

286+
#### Testing the Breach Alerts cron job locally
287+
288+
1. Ensure SMTP_URL environment variable is unset; this will log to JSON instead of attempting to send an email
289+
1. Follow instructions to start blurts server locally, including the database and emulated GCP PubSub topic
290+
1. Create a new account, and note the email address you used for the next step
291+
1. Update the email address below and paste into your terminal
292+
293+
```sh
294+
# Replace with whatever email address you used above, or omit and
295+
# export env var first to persist between runs
296+
297+
HIBP_TEST_EMAIL="[email protected]"; \
298+
HASH=$(echo -n "$HIBP_TEST_EMAIL" | sha1sum | awk '{print toupper($1)}'); \
299+
PREFIX=${HASH:0:7}; \
300+
SUFFIX=${HASH:7}; \
301+
curl -d "{\"breachName\": \"000webhost\", \"hashPrefix\": \"$PREFIX\", \"hashSuffixes\": [\"$SUFFIX\"]}" \
302+
-H "Authorization: Bearer unsafe-default-token-for-dev" \
303+
-H "Content-Type: application/json" \
304+
http://localhost:6060/api/v1/hibp/notify
305+
```
306+
307+
Note that the database must be seeded with breaches or else this request will not trigger emails due to validation error. The breachName must match the name of a breach in the database. Query the `breaches` table in the database for additional breach names to test more than once for the same email address (a user will be notified for a breach only once). Alternatively you can delete the record that was created in the `email_notifications` table to retest.
308+
284309
## Localization
285310

286311
All text that is visible to the user is defined in [Fluent](https://projectfluent.org/) files inside `/locales/en/` and `/locales-pending/`. After strings get added to files in the former directory on our `main` branch, they will be made available to our volunteer localizers via Pontoon, Mozilla's localization platform. Be sure to reference the [localization documentation](https://mozilla-l10n.github.io/documentation/localization/dev_best_practices.html) for best practices. It's best to only move the strings to `/locales/en/` when they are more-or-less final and ready for localization. Your PR should be automatically tagged with a reviewer from the [Mozilla L10n team](https://wiki.mozilla.org/L10n:Mozilla_Team) to approve your request.

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)