Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ GCP_PUBSUB_TOPIC_NAME=hibp-breaches
GCP_PUBSUB_SUBSCRIPTION_NAME=hibp-cron
PUBSUB_HOST=localhost
PUBSUB_PORT=8085
PUBSUB_EMULATOR_HOST="${PUBSUB_HOST}:${PUBSUB_PORT}"
PUBSUB_EMULATOR_HOST="localhost:8085"


# Randomly-generated UUIDv5 namespace, until/unless we are approved to use FxA UID for Nimbus User ID.
Expand Down
2 changes: 1 addition & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,4 @@ GCP_PUBSUB_TOPIC_NAME=hibp-breaches
GCP_PUBSUB_SUBSCRIPTION_NAME=hibp-cron
PUBSUB_HOST=localhost
PUBSUB_PORT=8085
PUBSUB_EMULATOR_HOST="${PUBSUB_HOST}:${PUBSUB_PORT}"
PUBSUB_EMULATOR_HOST=localhost:8085
2 changes: 1 addition & 1 deletion jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,4 @@ jest.mock("./src/envVars", () => {
// Avoiding putting in the env file in case this gets loaded into prod
// TODO: Centralize and streamline configuration for environments
// mozilla-hub.atlassian.net/browse/MNTOR-5089
https: process.env.PUBSUB_EMULATOR_HOST = "localhost:8085";
process.env.PUBSUB_EMULATOR_HOST = "localhost:8085";
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"dev:cron:first-data-broker-removal-fixed": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/firstDataBrokerRemovalFixed.tsx",
"dev:cron:monthly-activity-free": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/monthlyActivityFree.tsx",
"dev:cron:monthly-activity-plus": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/monthlyActivityPlus.tsx",
"dev:cron:breach-alerts": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/emailBreachAlerts.tsx",
"dev:cron:breach-alerts": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/emailBreachAlerts/index.ts",
"dev:cron:db-delete-unverified-subscribers": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/deleteUnverifiedSubscribers.ts",
"dev:cron:db-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/syncBreaches.ts",
"dev:cron:db-pull-data-brokers": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/syncOnerepDataBrokers.ts",
Expand All @@ -32,7 +32,7 @@
"cron:first-data-broker-removal-fixed": "node dist/scripts/cronjobs/firstDataBrokerRemovalFixed.js",
"cron:monthly-activity-free": "node dist/scripts/cronjobs/monthlyActivityFree.js",
"cron:monthly-activity-plus": "node dist/scripts/cronjobs/monthlyActivityPlus.js",
"cron:breach-alerts": "node dist/scripts/cronjobs/emailBreachAlerts.js",
"cron:breach-alerts": "node dist/scripts/cronjobs/emailBreachAlerts/index.js",
"cron:db-delete-unverified-subscribers": "node dist/scripts/cronjobs/deleteUnverifiedSubscribers.js",
"cron:db-pull-breaches": "node dist/scripts/cronjobs/syncBreaches.js",
"cron:db-pull-data-brokers": "node dist/scripts/cronjobs/syncOnerepDataBrokers.js",
Expand Down
4 changes: 2 additions & 2 deletions src/apiMocks/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export function createRandomHibpListing(
>,
Description: faker.lorem.sentence(),
Domain: faker.internet.domainName(),
Id: faker.number.int(),
Id: faker.number.int({ max: 2147483647 }),
IsFabricated: faker.datatype.boolean(),
IsMalware: faker.datatype.boolean(),
IsRetired: faker.datatype.boolean(),
Expand All @@ -243,7 +243,7 @@ export function createRandomHibpListing(
LogoPath: "unused",
ModifiedDate: faker.date.between({ from: addedDate, to: Date.now() }),
Name: name,
PwnCount: faker.number.int(),
PwnCount: faker.number.int({ max: 2147483647 }),
Title: title,
FaviconUrl: faker.helpers.maybe(() => {
const dimension = faker.number.int({ min: 20, max: 36 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { getEnabledFeatureFlags } from "../../../../../../db/tables/featureFlags
import { getExperimentationIdFromUserSession } from "../../../../../functions/server/getExperimentationId";
import { getExperiments } from "../../../../../functions/server/getExperiments";
import { getLocale } from "../../../../../functions/universal/getLocale";
import { BREACH_ALERT_UTM_CAMPAIGN_ID } from "../../../../../../constants";

async function getAdminSubscriber(): Promise<SubscriberRow | null> {
const session = await getServerSession();
Expand Down Expand Up @@ -243,14 +244,6 @@ export async function triggerBreachAlert(emailAddress: string) {
if (typeof subscriber.onerep_profile_id === "number") {
await refreshStoredScanResults(subscriber.onerep_profile_id);
}
const experimentationId = await getExperimentationIdFromUserSession(
session.user,
);
const experimentData = await getExperiments({
experimentationId,
countryCode: assumedCountryCode,
locale: getLocale(l10n),
});
const scanData = await getScanResultsWithBroker(
subscriber.onerep_profile_id,
hasPremium(session.user),
Expand All @@ -265,16 +258,15 @@ export async function triggerBreachAlert(emailAddress: string) {
l10n.getString("email-breach-alert-all-subject"),
<BreachAlertEmail
subscriber={subscriber}
breach={createRandomHibpListing()}
breachedEmail={emailAddress}
utmCampaignId="breach-alert"
breach={createRandomHibpListing()}
utmCampaignId={BREACH_ALERT_UTM_CAMPAIGN_ID}
l10n={l10n}
dataSummary={
isEligibleForPremium(assumedCountryCode) && !hasPremium(subscriber)
? getDashboardSummary(scanData.results, allSubscriberBreaches)
: undefined
}
experimentData={experimentData["Features"]}
/>,
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/app/api/v1/hibp/notify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ export async function POST(req: NextRequest) {
try {
const topic = pubsub.topic(topicName);
await topic.publishMessage({ json });
logger.info("queued_breach_notification_success", { json });
logger.info("queued_breach_notification_success", {
json,
topic: topicName,
});
return NextResponse.json({ success: true }, { status: 200 });
} catch {
if (process.env.NODE_ENV === "development") {
Expand Down
5 changes: 4 additions & 1 deletion src/app/functions/l10n/cronjobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ import type { SanitizedSubscriberRow } from "../server/sanitize";
import { parseMarkup } from "./parseMarkup";

export function getCronjobL10n(
subscriber: SanitizedSubscriberRow,
subscriber: Pick<SanitizedSubscriberRow, "signup_language">,
): ExtendedReactLocalization {
// We don't have a runtime language when we email people, so use their
// language setting from when they signed up for their Mozilla account:
// Low priority for unit testing as it just calls a different function
// with a default value provided if the input argument is undefined
/* c8 ignore next */
return getL10n(subscriber.signup_language ?? "en");
}

Expand Down
13 changes: 13 additions & 0 deletions src/app/functions/l10n/parseMarkup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { parseMarkup } from "./parseMarkup";

describe("parseMarkup", () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added due to coverage complaint (no changes to method)

it("exits early if no brackets and returns text node", () => {
expect(parseMarkup("some text")).toStrictEqual([
{ nodeName: "#text", textContent: "some text" },
]);
});
});
37 changes: 37 additions & 0 deletions src/app/functions/universal/mock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { isUsingMockHIBPEndpoint, isUsingMockONEREPEndpoint } from "./mock";
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added due to coverage complaint (no changes to the files)


describe("mock detectors", () => {
const originalEnv = process.env;
afterEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => (process.env = originalEnv));
describe("isUsingMockHibpEndpoint", () => {
it.each([
["http://localhost/v1/api/mock/path", true],
["api/mock", true],
["http://localhost/v1/api/path", false],
[undefined, false],
["", false],
])("detects mock path in HIBP_KANON_API_ROOT env var", (path, expected) => {
process.env.HIBP_KANON_API_ROOT = path;
expect(isUsingMockHIBPEndpoint()).toEqual(expected);
});
});
describe("isUsingMockONEREPEndpoint", () => {
it.each([
["http://localhost/v1/api/mock/path", true],
["api/mock", true],
["http://localhost/v1/api/path", false],
[undefined, false],
["", false],
])("detects mock path in ONEREP_API_BASE env var", (path, expected) => {
process.env.ONEREP_API_BASE = path;
expect(isUsingMockONEREPEndpoint()).toEqual(expected);
});
});
});
4 changes: 2 additions & 2 deletions src/app/functions/universal/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

export function isUsingMockHIBPEndpoint() {
return process.env.HIBP_KANON_API_ROOT?.includes("api/mock") as boolean;
return !!process.env.HIBP_KANON_API_ROOT?.includes("api/mock");
Copy link
Collaborator Author

@kschelonka kschelonka Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was returning undefined if HIBP_KANON_API_ROOT was unset; !! is idiomatic boolean conversion

}

export function isUsingMockONEREPEndpoint() {
return process.env.ONEREP_API_BASE?.includes("api/mock") as boolean;
return !!process.env.ONEREP_API_BASE?.includes("api/mock");
}

export const ONEREP_API_BASE = process.env.ONEREP_API_BASE;
4 changes: 3 additions & 1 deletion src/app/functions/universal/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { ISO8601DateString } from "../../../utils/parse";
import { SubscriberRow } from "knex/types/tables";
import type { FeatureFlagName } from "../../../db/tables/featureFlags";

export function hasPremium(user?: Session["user"] | SubscriberRow): boolean {
export function hasPremium(
user?: Pick<Session["user"], "fxa"> | Pick<SubscriberRow, "fxa_profile_json">,
): boolean {
const subscriptions =
// Simulating subscribers with incomplete FxA profile data
// is a bit too much effort for too little gain, hence:
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ export const CONST_DATA_BROKER_PROFILE_DETAIL_LIMITS = {
} as const;
export const CONST_MAX_SCAN_RESULTS_PER_BROKER = 3 as const;
export const CONST_CIRRUS_V2_PATHNAME = "v2/features";
export const BREACH_ALERT_UTM_CAMPAIGN_ID = "breach-alert";
168 changes: 168 additions & 0 deletions src/db/models/BreachNotificationSubscriber.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { seeds } from "../../test/db";
import createDbConnection from "../connect";
import { faker } from "@faker-js/faker";
import { breachNotificationSubscribersByHashes } from "./BreachNotificationSubscriber";
import type { SubscriberRow } from "knex/types/tables";

describe("BreachNotificationSubscriber", () => {
const subscriber = seeds.subscribers({ primary_verified: true });
const chaffSubs = Array.from(Array(10).keys()).map((_) =>
seeds.subscribers(),
);
let insertedSubscriber: SubscriberRow;

const conn = createDbConnection();
beforeEach(async () => {
insertedSubscriber = (
await conn("subscribers").insert(subscriber).returning("*")
)[0];
// Seed subscribers and emails
const insertedChaff = await conn("subscribers")
.insert(chaffSubs)
.returning("*");
const chaffEmails = insertedChaff.flatMap((subscriber) =>
Array.from(Array(faker.number.int({ max: 20 })).keys()).map((_) =>
seeds.emails(subscriber.id),
),
);
await conn("email_addresses").insert(chaffEmails);
});
afterEach(async () => {
await conn.raw(`TRUNCATE TABLE subscribers CASCADE`);
await conn.raw(`TRUNCATE TABLE email_addresses CASCADE`);
});
afterAll(async () => {
await conn.destroy();
});
it("returns rows with matching primary and secondary emails", async () => {
const insertedEmails = await conn("email_addresses")
.insert([
seeds.emails(insertedSubscriber.id, { verified: true }),
seeds.emails(insertedSubscriber.id, { verified: true }),
])
.returning("*");
const hashes = [insertedEmails[0].sha1, subscriber.primary_sha1];
const actual = await breachNotificationSubscribersByHashes(hashes);
expect(actual.length).toEqual(2);
expect(actual).toEqual(
expect.arrayContaining([
expect.objectContaining({
subscriber_id: insertedSubscriber.id,
breached_email: insertedSubscriber.primary_email,
primary_email: insertedSubscriber.primary_email,
}),
expect.objectContaining({
subscriber_id: insertedSubscriber.id,
breached_email: insertedEmails[0].email,
primary_email: insertedSubscriber.primary_email,
}),
]),
);
});
it("returns primary email only if no secondary emails match", async () => {
await conn("email_addresses")
.insert([
seeds.emails(insertedSubscriber.id, { verified: true }),
seeds.emails(insertedSubscriber.id, { verified: true }),
])
.returning("*");
const hashes = ["0000000000000", subscriber.primary_sha1];
const actual = await breachNotificationSubscribersByHashes(hashes);
expect(actual.length).toEqual(1);
expect(actual).toEqual(
expect.arrayContaining([
expect.objectContaining({
subscriber_id: insertedSubscriber.id,
breached_email: insertedSubscriber.primary_email,
primary_email: insertedSubscriber.primary_email,
}),
]),
);
});
it("returns secondary email only if no primary emails match", async () => {
const insertedEmails = await conn("email_addresses")
.insert([
seeds.emails(insertedSubscriber.id, { verified: true }),
seeds.emails(insertedSubscriber.id, { verified: true }),
])
.returning("*");
const hashes = ["0000000000000", insertedEmails[0].sha1];
const actual = await breachNotificationSubscribersByHashes(hashes);
expect(actual.length).toEqual(1);
expect(actual).toEqual(
expect.arrayContaining([
expect.objectContaining({
subscriber_id: insertedSubscriber.id,
breached_email: insertedEmails[0].email,
primary_email: insertedSubscriber.primary_email,
}),
]),
);
});
it("sets notification email to primary if preferred", async () => {
const primaryDefaultSub = (
await conn("subscribers")
.insert(
seeds.subscribers({
primary_verified: true,
all_emails_to_primary: true,
}),
)
.returning("*")
)[0];
const insertedEmails = await conn("email_addresses")
.insert([
seeds.emails(primaryDefaultSub.id, { verified: true }),
seeds.emails(primaryDefaultSub.id, { verified: true }),
])
.returning("*");
const hashes = ["0000000000000", insertedEmails[0].sha1];
const actual = await breachNotificationSubscribersByHashes(hashes);
expect(actual.length).toEqual(1);
expect(actual).toEqual(
expect.arrayContaining([
expect.objectContaining({
subscriber_id: primaryDefaultSub.id,
breached_email: insertedEmails[0].email,
primary_email: primaryDefaultSub.primary_email,
notification_email: primaryDefaultSub.primary_email,
}),
]),
);
});
it("sets notification email to secondary address if primary not preferred", async () => {
const primaryNotDefaultSub = (
await conn("subscribers")
.insert(
seeds.subscribers({
primary_verified: true,
all_emails_to_primary: false,
}),
)
.returning("*")
)[0];
const insertedEmails = await conn("email_addresses")
.insert([
seeds.emails(primaryNotDefaultSub.id, { verified: true }),
seeds.emails(primaryNotDefaultSub.id, { verified: true }),
])
.returning("*");
const hashes = ["0000000000000", insertedEmails[0].sha1];
const actual = await breachNotificationSubscribersByHashes(hashes);
expect(actual.length).toEqual(1);
expect(actual).toEqual(
expect.arrayContaining([
expect.objectContaining({
subscriber_id: primaryNotDefaultSub.id,
breached_email: insertedEmails[0].email,
primary_email: primaryNotDefaultSub.primary_email,
notification_email: insertedEmails[0].email,
}),
]),
);
});
});
Loading
Loading