|
| 1 | +/* This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | + * License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
| 4 | + |
| 5 | +import { |
| 6 | + describe, |
| 7 | + it, |
| 8 | + expect, |
| 9 | + vi, |
| 10 | + beforeEach, |
| 11 | + afterEach, |
| 12 | + afterAll, |
| 13 | +} from "vitest"; |
| 14 | +import { NextRequest } from "next/server"; |
| 15 | +import MockRedis from "ioredis-mock"; |
| 16 | +import createDbConnection from "../../../../../../db/connect"; |
| 17 | +import { seeds } from "../../../../../../test/db"; |
| 18 | +import { FXA_MONITOR_SCOPE } from "../../../../../../constants"; |
| 19 | + |
| 20 | +// Only FxA, HIBP and Redis are fake; Postgres is real |
| 21 | +vi.mock("../../../../../../utils/fxa", async (importOriginal) => ({ |
| 22 | + ...(await importOriginal<typeof import("../../../../../../utils/fxa")>()), |
| 23 | + introspectToken: vi.fn(), |
| 24 | +})); |
| 25 | + |
| 26 | +vi.mock("../../../../../../utils/hibp", async (importOriginal) => ({ |
| 27 | + ...(await importOriginal<typeof import("../../../../../../utils/hibp")>()), |
| 28 | + searchHibpRange: vi.fn(), |
| 29 | +})); |
| 30 | + |
| 31 | +const redis = new MockRedis(); |
| 32 | +vi.mock("../../../../../../db/redis/client", () => ({ |
| 33 | + redisClient: () => redis, |
| 34 | + REDIS_ALL_BREACHES_KEY: "breaches", |
| 35 | + BREACHES_EXPIRY_SECONDS: 43200, |
| 36 | +})); |
| 37 | + |
| 38 | +vi.mock("../../../../../functions/server/logging", () => ({ |
| 39 | + logger: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, |
| 40 | +})); |
| 41 | + |
| 42 | +import { introspectToken, getSha1 } from "../../../../../../utils/fxa"; |
| 43 | +import { searchHibpRange } from "../../../../../../utils/hibp"; |
| 44 | +import { GET } from "./route"; |
| 45 | + |
| 46 | +const conn = createDbConnection(); |
| 47 | + |
| 48 | +const FXA_UID = "integration-test-fxa-uid"; |
| 49 | +const PRIMARY = "Primary@Example.com"; |
| 50 | +const SECONDARY = "secondary@example.com"; |
| 51 | +const UNVERIFIED = "unverified@example.com"; |
| 52 | + |
| 53 | +/** A breach every filter lets through */ |
| 54 | +function breachSeed(name: string, addedDate: string) { |
| 55 | + return seeds.breaches({ |
| 56 | + name, |
| 57 | + domain: `${name.toLowerCase()}.com`, |
| 58 | + added_date: new Date(addedDate), |
| 59 | + data_classes: ["passwords", "email-addresses"], |
| 60 | + is_verified: true, |
| 61 | + is_fabricated: false, |
| 62 | + is_sensitive: false, |
| 63 | + is_retired: false, |
| 64 | + is_spam_list: false, |
| 65 | + }); |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * Seeds a subscriber, their emails, breaches and the |
| 70 | + * FirefoxApi flag. Returns the breach ids by name. |
| 71 | + */ |
| 72 | +async function seedUser(flagAllowList: string[]) { |
| 73 | + const breaches = await conn("breaches") |
| 74 | + .insert([ |
| 75 | + breachSeed("Adobe", "2020-01-01"), |
| 76 | + breachSeed("LinkedIn", "2026-01-01"), |
| 77 | + { ...breachSeed("Sensitive", "2025-01-01"), is_sensitive: true }, |
| 78 | + ]) |
| 79 | + .returning(["id", "name"]); |
| 80 | + const ids = Object.fromEntries(breaches.map((b) => [b.name, b.id])); |
| 81 | + |
| 82 | + const [subscriber] = await conn("subscribers") |
| 83 | + .insert( |
| 84 | + seeds.subscribers({ |
| 85 | + fxa_uid: FXA_UID, |
| 86 | + primary_email: PRIMARY, |
| 87 | + primary_verified: true, |
| 88 | + // Stored as the website's resolve route writes it |
| 89 | + breach_resolution: { |
| 90 | + useBreachId: true, |
| 91 | + [PRIMARY]: { [ids.Adobe]: { resolutionsChecked: ["passwords"] } }, |
| 92 | + }, |
| 93 | + }), |
| 94 | + ) |
| 95 | + .returning("*"); |
| 96 | + |
| 97 | + await conn("email_addresses").insert([ |
| 98 | + seeds.emails(subscriber.id, { email: SECONDARY, verified: true }), |
| 99 | + seeds.emails(subscriber.id, { email: UNVERIFIED, verified: false }), |
| 100 | + ]); |
| 101 | + |
| 102 | + await conn("feature_flag_events").insert({ |
| 103 | + name: "FirefoxApi", |
| 104 | + is_enabled: false, |
| 105 | + allow_list: flagAllowList, |
| 106 | + created_by_subscriber_id: subscriber.id, |
| 107 | + }); |
| 108 | +} |
| 109 | + |
| 110 | +/** HIBP lists these breach names, per email */ |
| 111 | +function mockHibp(namesByEmail: Record<string, string[]>) { |
| 112 | + vi.mocked(searchHibpRange).mockImplementation(async (sha1) => { |
| 113 | + for (const [email, names] of Object.entries(namesByEmail)) { |
| 114 | + if (getSha1(email.toLowerCase()) === sha1) return names; |
| 115 | + } |
| 116 | + return []; |
| 117 | + }); |
| 118 | +} |
| 119 | + |
| 120 | +function authedRequest() { |
| 121 | + return new NextRequest("http://localhost/api/firefox/v1/user/breaches", { |
| 122 | + headers: { authorization: "Bearer fxa-access-token" }, |
| 123 | + }); |
| 124 | +} |
| 125 | + |
| 126 | +beforeEach(async () => { |
| 127 | + // E2E "local" reads headers(), needing a request |
| 128 | + vi.stubEnv("E2E_TEST_ENV", "ci"); |
| 129 | + await redis.flushall(); |
| 130 | + vi.mocked(introspectToken).mockResolvedValue({ |
| 131 | + active: true, |
| 132 | + sub: FXA_UID, |
| 133 | + scope: FXA_MONITOR_SCOPE, |
| 134 | + client_id: "integration-client", |
| 135 | + token_type: "access_token", |
| 136 | + }); |
| 137 | +}); |
| 138 | + |
| 139 | +afterEach(async () => { |
| 140 | + vi.clearAllMocks(); |
| 141 | + vi.unstubAllEnvs(); |
| 142 | + await conn.raw("TRUNCATE TABLE subscribers, breaches CASCADE"); |
| 143 | +}); |
| 144 | + |
| 145 | +afterAll(async () => { |
| 146 | + await conn.destroy(); |
| 147 | +}); |
| 148 | + |
| 149 | +describe("GET /api/firefox/v1/user/breaches (integration)", () => { |
| 150 | + it("returns each email's breaches and resolutions from Postgres", async () => { |
| 151 | + await seedUser([PRIMARY]); |
| 152 | + mockHibp({ |
| 153 | + [PRIMARY]: ["Adobe", "Sensitive"], |
| 154 | + [SECONDARY]: ["Adobe", "LinkedIn"], |
| 155 | + [UNVERIFIED]: ["LinkedIn"], |
| 156 | + }); |
| 157 | + |
| 158 | + const res = await GET(authedRequest()); |
| 159 | + |
| 160 | + expect(res.status).toBe(200); |
| 161 | + expect(await res.json()).toEqual([ |
| 162 | + { |
| 163 | + id: "LinkedIn", |
| 164 | + breachedAccounts: [{ email: SECONDARY, resolvedDataClasses: [] }], |
| 165 | + }, |
| 166 | + { |
| 167 | + id: "Adobe", |
| 168 | + breachedAccounts: [ |
| 169 | + { email: PRIMARY, resolvedDataClasses: ["passwords"] }, |
| 170 | + { email: SECONDARY, resolvedDataClasses: [] }, |
| 171 | + ], |
| 172 | + }, |
| 173 | + ]); |
| 174 | + // The unverified email is never looked up |
| 175 | + expect(searchHibpRange).toHaveBeenCalledTimes(2); |
| 176 | + }); |
| 177 | + |
| 178 | + it("returns 503 when the user is not on the flag's allow list", async () => { |
| 179 | + await seedUser(["someone-else@example.com"]); |
| 180 | + |
| 181 | + const res = await GET(authedRequest()); |
| 182 | + |
| 183 | + expect(res.status).toBe(503); |
| 184 | + expect(searchHibpRange).not.toHaveBeenCalled(); |
| 185 | + }); |
| 186 | +}); |
0 commit comments