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
4 changes: 4 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,7 @@ if (typeof global.TextDecoder === "undefined") {
// TODO: Centralize and streamline configuration for environments
// https://mozilla-hub.atlassian.net/browse/MNTOR-5089
process.env.PUBSUB_EMULATOR_HOST = "localhost:8085";

// node tests use fetch but jsom env doesn't include it in namespace
// add as a mock since we don't want to do real fetches anyway
global.fetch = jest.fn();
9 changes: 9 additions & 0 deletions src/db/tables/breaches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,21 @@ async function updateBreachFaviconUrl(name: string, faviconUrl: string | null) {
favicon_url: faviconUrl,
});
}

async function getBreachFaviconUrl(name: string) {
const res = await knex("breaches")
.where("name", name)
.select("favicon_url")
.first();
return res?.favicon_url;
}
/* c8 ignore stop */

export {
getAllBreaches,
getAllBreachesCount,
upsertBreaches,
updateBreachFaviconUrl,
getBreachFaviconUrl,
knex,
};
36 changes: 36 additions & 0 deletions src/scripts/cronjobs/syncBreaches/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* 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 * as Sentry from "@sentry/node";
import { config } from "../../../config";
import { run } from "./syncBreaches";

Sentry.init({
dsn: config.sentryDsn,
tracesSampleRate: 1.0,
});

const SENTRY_SLUG = "cron-sync-breaches";

const checkInId = Sentry.captureCheckIn({
monitorSlug: SENTRY_SLUG,
status: "in_progress",
});

try {
await run();
Sentry.captureCheckIn({
checkInId,
monitorSlug: SENTRY_SLUG,
status: "ok",
});
} catch (error) {
Sentry.captureCheckIn({
checkInId,
monitorSlug: SENTRY_SLUG,
status: "error",
});
throw error;
}
setTimeout(process.exit, 1000);
162 changes: 162 additions & 0 deletions src/scripts/cronjobs/syncBreaches/syncBreaches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* 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 hibpBreachMock from "../../../test/seeds/hibpBreachResponse.json";
import { getBreachIcons } from "./syncBreaches";
import { HibpGetBreachesResponse } from "../../../utils/hibp";

const fetchSpy = jest.spyOn(global, "fetch");

jest.mock("../../../utils/s3", () => ({
__esModule: true,
uploadToS3: jest.fn().mockResolvedValue(undefined),
checkS3ObjectExists: jest.fn().mockResolvedValue(false),
}));

jest.mock("../../../db/tables/breaches", () => ({
__esModule: true,
updateBreachFaviconUrl: jest.fn().mockResolvedValue(undefined),
getBreachFaviconUrl: jest.fn(),
}));

jest.mock("../../../app/functions/server/logging", () => {
const { mockLogger } = require("../../../test/helpers/mockLogger");
return {
__esModule: true,
logger: mockLogger(),
};
});

const mockGetBreachFaviconUrls = (
favicons: Array<string | null | undefined>,
) => {
(getBreachFaviconUrl as jest.Mock).mockRestore();
favicons.forEach((favicon) =>
(getBreachFaviconUrl as jest.Mock).mockResolvedValueOnce(favicon),
);
};

const buildBreachFavicon = (breachDomain: string) =>
`https://s3.amazonaws.com/${process.env.S3_BUCKET}/${breachDomain.toLowerCase()}.ico`;

import { uploadToS3, checkS3ObjectExists } from "../../../utils/s3";
import {
getBreachFaviconUrl,
updateBreachFaviconUrl,
} from "../../../db/tables/breaches";

describe("syncBreaches", () => {
describe("getBreachIcons", () => {
beforeEach(() => {
// Set up default mocks which return a synced
// favicon record (only checked if s3 object exists)
mockGetBreachFaviconUrls(
breaches.map((breach) => buildBreachFavicon(breach.Domain)),
);
});
const breaches = hibpBreachMock.slice(0, 2) as HibpGetBreachesResponse;
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => jest.restoreAllMocks());
it("only fetches and uploads icons not already in s3", async () => {
fetchSpy.mockResolvedValue({
status: 200,
arrayBuffer: async () => Buffer.from("abc"),
} as unknown as Response);
(checkS3ObjectExists as jest.Mock)
.mockResolvedValueOnce(true)
.mockResolvedValue(false);
await getBreachIcons(breaches);
expect(uploadToS3).toHaveBeenCalledTimes(breaches.length - 1);
});
it("skips breaches without domains", async () => {
const noDomains = breaches.map((breach) => ({ ...breach, Domain: "" }));
await getBreachIcons(noDomains);
expect(updateBreachFaviconUrl).toHaveBeenCalledTimes(noDomains.length);
expect(uploadToS3).toHaveBeenCalledTimes(0);
});
it("syncs db value if logo is in s3 but not the same as in the db", async () => {
const favicons = [
undefined,
"not-a-match",
buildBreachFavicon(breaches[0].Domain),
];
(checkS3ObjectExists as jest.Mock).mockResolvedValue(true);
mockGetBreachFaviconUrls(favicons);
await getBreachIcons([breaches[0], breaches[1], breaches[0]]);
expect(updateBreachFaviconUrl).toHaveBeenCalledTimes(2);
expect(updateBreachFaviconUrl).toHaveBeenNthCalledWith(
1,
breaches[0].Name,
buildBreachFavicon(breaches[0].Domain),
);
expect(updateBreachFaviconUrl).toHaveBeenNthCalledWith(
2,
breaches[1].Name,
buildBreachFavicon(breaches[1].Domain),
);
});
it("skips uploading if logo fetch status code is not 200", async () => {
fetchSpy
.mockResolvedValueOnce({
status: 500,
} as unknown as Response)
.mockResolvedValue({
status: 200,
arrayBuffer: async () => Buffer.from("abc"),
} as unknown as Response);
await getBreachIcons(breaches);
expect(updateBreachFaviconUrl).toHaveBeenCalledTimes(breaches.length);
expect(uploadToS3).toHaveBeenCalledTimes(breaches.length - 1);
});
it("continues processing if error is thrown in fetch", async () => {
fetchSpy.mockRejectedValueOnce(new Error("nope")).mockResolvedValue({
status: 200,
arrayBuffer: async () => Buffer.from("abc"),
} as unknown as Response);
await getBreachIcons(breaches);
expect(updateBreachFaviconUrl).toHaveBeenCalledTimes(breaches.length - 1);
expect(uploadToS3).toHaveBeenCalledTimes(breaches.length - 1);
});
it("continues processing if error is thrown in checkS3ObjectExists", async () => {
fetchSpy.mockResolvedValue({
status: 200,
arrayBuffer: async () => Buffer.from("abc"),
} as unknown as Response);
(checkS3ObjectExists as jest.Mock)
.mockRejectedValueOnce(new Error("nope"))
.mockResolvedValue(false);
await getBreachIcons(breaches);
expect(updateBreachFaviconUrl).toHaveBeenCalledTimes(breaches.length - 1);
expect(uploadToS3).toHaveBeenCalledTimes(breaches.length - 1);
});
it("continues processing if error is thrown in updateBreachFaviconUrl", async () => {
fetchSpy.mockResolvedValue({
status: 200,
arrayBuffer: async () => Buffer.from("abc"),
} as unknown as Response);
(updateBreachFaviconUrl as jest.Mock)
.mockRejectedValueOnce(new Error("nope"))
.mockResolvedValue(undefined);
await getBreachIcons(breaches);
// uploadToS3 is called before updateBreachFaviconurl
expect(uploadToS3).toHaveBeenCalledTimes(breaches.length);
// still called the same number of times although one throws
expect(updateBreachFaviconUrl).toHaveBeenCalledTimes(breaches.length);
});
it("continues processing if error is thrown in uploadToS3", async () => {
fetchSpy.mockResolvedValue({
status: 200,
arrayBuffer: async () => Buffer.from("abc"),
} as unknown as Response);
(uploadToS3 as jest.Mock)
.mockRejectedValueOnce(new Error("nope"))
.mockResolvedValue(undefined);
await getBreachIcons(breaches);
expect(uploadToS3).toHaveBeenCalledTimes(breaches.length);
expect(updateBreachFaviconUrl).toHaveBeenCalledTimes(breaches.length - 1);
});
});
});
Loading
Loading