// The shipped Content Security Policy, pinned in both directions.
//
// This is the anti-regression check for #182. libsodium decides its
// backend by trying to compile WebAssembly and catching the failure, so a
// CSP that refuses WASM demotes the vault to the wasm2js translation —
// roughly 20x slower per Argon2id derivation — and says so only in a
// console message nobody reads. Dropping 'wasm-unsafe-eval' from either
// manifest therefore has to fail a check, not a log line.
//
// It is equally a check against loosening. 'wasm-unsafe-eval' is granted
// deliberately and narrowly (see the backend note in src/shared/vault.js);
// 'unsafe-eval', 'unsafe-inline' and any remote script source are not, and
// an exact match on the token set is what keeps the next edit from
// smuggling one in alongside.
//
// It is also the anti-regression check for #307. The policy used to declare
// script-src and object-src and nothing else, which left every directive
// that does not fall back to them — and, absent default-src, every one that
// does — wide open: a hostile ERC-20 symbol that reached innerHTML could
// load a full-viewport cross-origin iframe over the wallet's own UI. The
// escaping in src/shared/html.js is the primary fix; default-src is what
// stops the next escape that slips from reaching the network.
//
// Every directive below is pinned exactly, because each of the four
// loosenings is load-bearing and none of them may grow:
//
//   style-src 'unsafe-inline'  src/popup/index.html and the view helpers
//                              use style="..." attributes throughout, which
//                              CSP blocks without it. Chrome enforces this
//                              on attributes, not just <style> blocks, and
//                              Firefox has never implemented style-src-attr,
//                              so there is no narrower spelling available.
//   img-src data:              blockies are data: PNGs assigned to img.src.
//   connect-src https: http:   the RPC endpoint is user-configurable, and a
//                              local node over http://127.0.0.1 is a
//                              supported configuration — the Firefox e2e
//                              suite runs on exactly that.
//   frame-src/form-action/base-uri  named rather than inherited: form-action
//                              and base-uri do not fall back to default-src
//                              at all, and frame-src 'none' is what kills
//                              the reported attack outright.
//
// build.js copies these files to dist/<target>/manifest.json verbatim, so
// what is asserted here is what ships.

const fs = require("fs");
const path = require("path");

const MANIFEST_DIR = path.join(__dirname, "..", "manifest");

const EXPECTED_DIRECTIVES = {
    "default-src": ["'self'"],
    "script-src": ["'self'", "'wasm-unsafe-eval'"],
    "object-src": ["'self'"],
    "style-src": ["'self'", "'unsafe-inline'"],
    "img-src": ["'self'", "data:"],
    "connect-src": ["'self'", "http:", "https:"],
    "frame-src": ["'none'"],
    "form-action": ["'none'"],
    "base-uri": ["'none'"],
};

// Directives that fetch script. Nothing that can execute code may name a
// remote source, an eval form, or an inline form; 'wasm-unsafe-eval' is the
// single deliberate exception and it is pinned above.
const SCRIPT_DIRECTIVES = ["default-src", "script-src", "object-src"];

const FORBIDDEN_SOURCES = [
    "'unsafe-eval'",
    "'unsafe-inline'",
    "http:",
    "https:",
    "data:",
    "blob:",
    "*",
];

function readManifest(name) {
    return JSON.parse(
        fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"),
    );
}

// "script-src 'self'; object-src 'self'" -> { "script-src": ["'self'"], ... }
function parseCsp(policy) {
    const directives = {};
    for (const part of policy.split(";")) {
        const tokens = part.trim().split(/\s+/).filter(Boolean);
        if (tokens.length === 0) continue;
        directives[tokens[0]] = tokens.slice(1);
    }
    return directives;
}

function assertPolicy(policy) {
    const directives = parseCsp(policy);
    // Exact, in both directions: a directive that appears here and not in
    // EXPECTED_DIRECTIVES is an unreviewed addition, and one that
    // disappears silently reopens whatever it was closing.
    expect(Object.keys(directives).sort()).toEqual(
        Object.keys(EXPECTED_DIRECTIVES).sort(),
    );
    for (const [name, sources] of Object.entries(EXPECTED_DIRECTIVES)) {
        expect([name, directives[name].slice().sort()]).toEqual([
            name,
            sources.slice().sort(),
        ]);
    }
    for (const name of SCRIPT_DIRECTIVES) {
        for (const source of FORBIDDEN_SOURCES) {
            expect(name + " " + directives[name].join(" ")).not.toContain(
                " " + source,
            );
        }
    }
}

describe("shipped Content Security Policy", () => {
    // MV3 takes an object and applies extension_pages to the popup and the
    // background service worker, which is where libsodium runs.
    test("chrome MV3 ships the pinned policy, default-src included", () => {
        const csp = readManifest("chrome").content_security_policy;
        expect(typeof csp).toBe("object");
        expect(Object.keys(csp)).toEqual(["extension_pages"]);
        assertPolicy(csp.extension_pages);
    });

    // MV2 takes the policy as a bare string. Firefox does not require
    // 'wasm-unsafe-eval' for MV2 today — enforcement is report-only and
    // Bugzilla 1770909 is still open — so that token is future-proofing
    // for when it lands, not a mandate, and it stays inside Firefox's MV2
    // base-CSP ceiling. object-src 'self' is the load-bearing half: a
    // Firefox before 106 rejects an MV2 policy string that omits
    // object-src and falls back to its own default, discarding everything
    // declared here. Same policy as Chrome, different manifest shape.
    test("firefox MV2 ships the pinned policy, default-src included", () => {
        const csp = readManifest("firefox").content_security_policy;
        expect(typeof csp).toBe("string");
        assertPolicy(csp);
    });

    // The two targets share one codebase and one crypto path; a policy
    // that drifts apart between them means one of the two builds is
    // running a backend nothing tests.
    test("both targets ship the same policy", () => {
        const chrome =
            readManifest("chrome").content_security_policy.extension_pages;
        const firefox = readManifest("firefox").content_security_policy;
        expect(firefox).toBe(chrome);
    });
});
