// The phishing blocklist is vendored at build time and shipped as digests:
// script/vendor-blocklist writes src/shared/phishingBlocklist.json, and nothing
// fetches anything at runtime. Two things therefore have to be proven here, and
// the second is the one that would otherwise fail silently:
//
//   - real domains from the vendored list are detected, and clean ones are not.
//   - a malformed artifact fails loudly. Every way of getting the artifact
//     wrong produces a blocklist that matches nothing while looking healthy,
//     which is a phishing check that answers "no" to everything.

const {
    isPhishingDomain,
    getBlocklistSize,
    hostnameVariants,
} = require("../src/shared/phishingDomains");
const { HASH_HEX_CHARS, hashDomain } = require("../src/shared/domainHash");
const vendored = require("../src/shared/phishingBlocklist.json");

// Domains present in the vendored list at the pinned upstream commit. Upstream
// prunes as well as adds, so re-vendoring can retire one of these and turn this
// red; that is the intended prompt to pick a current entry, not a licence to
// weaken the assertion into "some domain somewhere matches".
const LISTED = [
    "0-google.ph",
    "myetheywallet.com",
    // An underscore is not legal in a hostname, but DNS carries one and
    // browsers resolve it, and upstream lists well over a hundred phishing
    // sites that use one. The vendoring transform keeps them.
    "phntum-wallett.godaddysites.com",
    "coinbase_prologin1.godaddysites.com",
];

// Not on the list, and the kind of host a user actually visits.
const CLEAN = ["etherscan.io", "example.com", "opensea.io", "sneak.berlin"];

describe("vendored blocklist", () => {
    test("the artifact holds the whole list", () => {
        expect(getBlocklistSize()).toBeGreaterThan(100000);
        expect(vendored.hashes).toHaveLength(vendored.count * HASH_HEX_CHARS);
    });

    test("the digests are sorted and unique", () => {
        // The lookup is a binary search over the concatenated digests. An
        // unsorted or duplicated artifact would fail lookups quietly rather
        // than loudly, so the ordering the search depends on is asserted here
        // against the committed file rather than assumed of the generator.
        // One assertion at the end rather than one per entry: 100k+ expect()
        // calls cost seconds, and make test is capped at 30 for the whole
        // suite. The index of the first offender is reported, so a failure
        // still says where.
        let previous = "";
        let outOfOrderAt = -1;
        for (let i = 0; i < vendored.count; i++) {
            const at = vendored.hashes.slice(
                i * HASH_HEX_CHARS,
                (i + 1) * HASH_HEX_CHARS,
            );
            if (at <= previous) {
                outOfOrderAt = i;
                break;
            }
            previous = at;
        }
        expect(outOfOrderAt).toBe(-1);
    });

    test("every digest is lowercase hex of the declared width", () => {
        expect(vendored.hashes).toMatch(/^[0-9a-f]*$/);
    });

    test("detects domains from the vendored list", () => {
        for (const domain of LISTED) {
            expect(isPhishingDomain(domain)).toBe(true);
        }
    });

    test("does not flag legitimate domains", () => {
        for (const domain of CLEAN) {
            expect(isPhishingDomain(domain)).toBe(false);
        }
    });

    test("detects a subdomain of a listed domain", () => {
        expect(isPhishingDomain("wallet." + LISTED[0])).toBe(true);
        expect(isPhishingDomain("a.b.c." + LISTED[0])).toBe(true);
    });

    test("matching is case-insensitive", () => {
        expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
    });

    test("returns false for an empty or missing hostname", () => {
        expect(isPhishingDomain("")).toBe(false);
        expect(isPhishingDomain(null)).toBe(false);
        expect(isPhishingDomain(undefined)).toBe(false);
    });

    test("the first and last entries are both reachable", () => {
        // The ends are where an off-by-one in a binary search hides: a search
        // that never examines index 0 or index count-1 still finds everything
        // in between, and the real list is not searched exhaustively here.
        const first = vendored.hashes.slice(0, HASH_HEX_CHARS);
        const last = vendored.hashes.slice(-HASH_HEX_CHARS);
        const { _hashListed } = require("../src/shared/phishingDomains");
        expect(_hashListed(first)).toBe(true);
        expect(_hashListed(last)).toBe(true);
        expect(_hashListed("0".repeat(HASH_HEX_CHARS))).toBe(false);
        expect(_hashListed("f".repeat(HASH_HEX_CHARS))).toBe(false);
    });
});

describe("hostnameVariants", () => {
    test("returns exact hostname plus parent domains", () => {
        expect(hostnameVariants("sub.evil.com")).toEqual([
            "sub.evil.com",
            "evil.com",
        ]);
    });

    test("returns just the hostname for a bare domain", () => {
        expect(hostnameVariants("example.com")).toEqual(["example.com"]);
    });

    test("handles deep subdomain chains", () => {
        expect(hostnameVariants("a.b.c.d.com")).toEqual([
            "a.b.c.d.com",
            "b.c.d.com",
            "c.d.com",
            "d.com",
        ]);
    });

    test("lowercases hostnames", () => {
        expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
    });
});

describe("domain hashing", () => {
    test("a digest is the declared width of lowercase hex", () => {
        const hash = hashDomain("example.com");
        expect(hash).toHaveLength(HASH_HEX_CHARS);
        expect(hash).toMatch(/^[0-9a-f]+$/);
    });

    test("hashing is case-insensitive, so lookups are too", () => {
        expect(hashDomain("Evil.COM")).toBe(hashDomain("evil.com"));
    });

    test("different domains get different digests", () => {
        expect(hashDomain("evil.com")).not.toBe(hashDomain("evil.org"));
    });
});

// A blocklist that silently matches nothing is the failure this module must not
// have, so each way of breaking the artifact is required to throw at load. The
// generator is the only thing that writes this file, but "the generator is
// correct" is not something the shipped extension can check at runtime — this
// is what makes a format drift a build failure rather than a silent one.
describe("a malformed artifact fails loudly", () => {
    const GOOD = {
        algorithm: "sha256",
        hashHexChars: HASH_HEX_CHARS,
        count: 2,
        hashes: "0".repeat(HASH_HEX_CHARS) + "1".repeat(HASH_HEX_CHARS),
    };

    function loadWith(artifact) {
        let mod;
        jest.isolateModules(() => {
            jest.doMock(
                "../src/shared/phishingBlocklist.json",
                () => artifact,
                {
                    virtual: false,
                },
            );
            mod = require("../src/shared/phishingDomains");
        });
        return mod;
    }

    afterEach(() => {
        jest.dontMock("../src/shared/phishingBlocklist.json");
    });

    test("the control artifact loads", () => {
        expect(loadWith(GOOD).getBlocklistSize()).toBe(2);
    });

    test("a different digest algorithm throws", () => {
        expect(() => loadWith({ ...GOOD, algorithm: "md5" })).toThrow(
            /algorithm/,
        );
    });

    test("a different digest width throws", () => {
        expect(() => loadWith({ ...GOOD, hashHexChars: 8 })).toThrow(
            /hex characters per entry/,
        );
    });

    test("a count that does not match the string length throws", () => {
        expect(() => loadWith({ ...GOOD, count: 3 })).toThrow(
            /which is not the/,
        );
    });

    test("a missing hashes string throws", () => {
        expect(() => loadWith({ ...GOOD, hashes: undefined })).toThrow(
            /no hashes string/,
        );
    });

    test("an empty artifact throws rather than matching nothing", () => {
        expect(() => loadWith({ ...GOOD, count: 0, hashes: "" })).toThrow(
            /entry count/,
        );
    });
});
