// The release archives: the zip container, and the self-containment rule.
//
// script/package produces one archive per browser and script/lib/package.js
// decides what goes in it. The trap that rule exists for is real and specific:
// build.js writes the compiled stylesheet to dist/styles.css at the dist/
// ROOT, outside both browser directories, and copies it into each of them as
// src/popup/styles.css. A `zip -r dist/chrome` is correct only because of that
// copy, and would silently start shipping a popup with no stylesheet the
// moment a reference pointed up and out of the directory.
//
// So the packager resolves every reference in the manifest and in every HTML
// document, and fails on any that leaves the extension root. These are the
// cases for that, plus the archive format itself — new code, and the thing the
// artifact is made of.

const {
    checkSelfContained,
    htmlReferences,
    manifestReferences,
} = require("../script/lib/package");
const { readZip, writeZip } = require("../script/lib/zip");

function archiveOf(files) {
    return {
        members: Object.keys(files).sort(),
        read: (name) => Buffer.from(files[name] ?? "", "utf8"),
    };
}

const MINIMAL_MANIFEST = {
    manifest_version: 3,
    name: "AutistMask",
    version: "0.1.0",
    action: { default_popup: "src/popup/index.html" },
    background: { service_worker: "src/background/index.js" },
};

describe("archive self-containment", () => {
    test("a complete tree passes", () => {
        const { members, read } = archiveOf({
            "manifest.json": JSON.stringify(MINIMAL_MANIFEST),
            "src/popup/index.html":
                '<link rel="stylesheet" href="styles.css" />' +
                '<script src="index.js"></script>',
            "src/popup/styles.css": "body{}",
            "src/popup/index.js": "//",
            "src/background/index.js": "//",
        });
        expect(() => checkSelfContained("chrome", members, read)).not.toThrow();
    });

    test("a manifest naming a file that is not in the archive fails", () => {
        const { members, read } = archiveOf({
            "manifest.json": JSON.stringify(MINIMAL_MANIFEST),
            "src/popup/index.html": "<html></html>",
            "src/popup/index.js": "//",
        });
        expect(() => checkSelfContained("chrome", members, read)).toThrow(
            /would not be self-contained.*src\/background\/index\.js/s,
        );
    });

    // The dist/styles.css case, exactly: a popup that reached up out of its
    // own browser directory for the stylesheet the build leaves at the dist/
    // root. Nothing would be missing from disk, and the zip would still be
    // built — the archive would just have no stylesheet in it.
    test("an HTML reference that escapes the extension root fails", () => {
        const { members, read } = archiveOf({
            "manifest.json": JSON.stringify(MINIMAL_MANIFEST),
            "src/popup/index.html":
                '<link rel="stylesheet" href="../../../styles.css" />',
            "src/popup/index.js": "//",
            "src/background/index.js": "//",
        });
        expect(() => checkSelfContained("chrome", members, read)).toThrow(
            /resolves outside the extension root/,
        );
    });

    test("a manifest reference that escapes the extension root fails", () => {
        const { members, read } = archiveOf({
            "manifest.json": JSON.stringify({
                ...MINIMAL_MANIFEST,
                background: { service_worker: "../shared/index.js" },
            }),
            "src/popup/index.html": "<html></html>",
        });
        expect(() => checkSelfContained("chrome", members, read)).toThrow(
            /points outside the extension root/,
        );
    });

    test("an archive with no manifest.json at its root fails", () => {
        const { members, read } = archiveOf({ "src/popup/index.js": "//" });
        expect(() => checkSelfContained("chrome", members, read)).toThrow(
            /no manifest\.json at its root/,
        );
    });

    test("manifest strings that are not paths are not treated as files", () => {
        const found = manifestReferences({
            name: "AutistMask",
            version: "0.1.0",
            permissions: ["storage", "<all_urls>"],
            content_security_policy: {
                extension_pages: "default-src 'self'; script-src 'self'",
            },
            key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy",
        });
        expect([...found]).toEqual([]);
    });

    test("absolute, data: and anchor HTML references are not files", () => {
        const refs = htmlReferences(
            "src/popup/index.html",
            '<a href="https://example.com/x.js">a</a>' +
                '<img src="data:image/png;base64,AAAA" />' +
                '<a href="#view-main">b</a>' +
                '<script src="index.js"></script>',
        );
        expect([...refs]).toEqual(["src/popup/index.js"]);
    });
});

describe("the zip container", () => {
    const files = [
        { name: "manifest.json", data: Buffer.from('{"a":1}') },
        // Long enough that deflate wins, so both paths through compress() are
        // exercised by one archive.
        { name: "src/popup/index.js", data: Buffer.from("x".repeat(5000)) },
        { name: "empty.txt", data: Buffer.alloc(0) },
    ];

    test("round-trips every member byte for byte", () => {
        const entries = readZip(writeZip(files));
        expect(entries.map((e) => e.name)).toEqual([
            "empty.txt",
            "manifest.json",
            "src/popup/index.js",
        ]);
        for (const original of files) {
            const found = entries.find((e) => e.name === original.name);
            expect(found.data.equals(original.data)).toBe(true);
        }
    });

    // The sha256 in release/SHA256SUMS has to be a property of the input. Two
    // builds of one commit that produce different archives cannot be compared
    // to each other, which is most of what publishing a digest is for.
    test("is byte-identical across runs and independent of input order", () => {
        const a = writeZip(files);
        const b = writeZip([...files].reverse());
        expect(a.equals(b)).toBe(true);
    });

    // Reading an archive back is how script/lib/package.js establishes that
    // the artifact holds what dist/ holds, so a member whose bytes changed
    // after it was written has to fail rather than be handed back.
    test("a corrupted member fails its CRC on read", () => {
        // One member, incompressible at this size, so it is stored verbatim
        // and its bytes begin at a known offset: local header (30) + name.
        const name = "a.js";
        const archive = writeZip([{ name, data: Buffer.from("hello") }]);
        expect(readZip(archive)[0].data.toString()).toBe("hello");
        archive[30 + name.length] ^= 0xff;
        expect(() => readZip(archive)).toThrow(/fails its recorded CRC32/);
    });

    test.each([["/abs.js"], ["../up.js"], ["a/../b.js"], ["a\\b.js"], [""]])(
        "refuses the member name %p",
        (name) => {
            expect(() => writeZip([{ name, data: Buffer.from("x") }])).toThrow(
                /member name/,
            );
        },
    );

    test("refuses an archive with no members", () => {
        expect(() => writeZip([])).toThrow(/no members/);
    });

    test("refuses duplicate members", () => {
        expect(() =>
            writeZip([
                { name: "a.js", data: Buffer.from("1") },
                { name: "a.js", data: Buffer.from("2") },
            ]),
        ).toThrow(/duplicate member/);
    });
});
