// The background's approval message wiring, driven end to end: a dApp
// eth_sendTransaction raises a pending approval, and the popup answers it with
// AUTISTMASK_TX_RESPONSE / AUTISTMASK_SIGN_RESPONSE.
//
// What this exists for is the duplicate response. The handler verifies and
// broadcasts asynchronously, and the approval deliberately survives a
// retryable failure so the user can try again with the transaction they
// already saw — which means the entry being present is not by itself proof
// that no attempt is running. A second response carrying the same id (a
// reloaded approval window re-rendering a live Approve button, a popup that
// emits the message twice) must not start a second verify and broadcast: with
// the ordinary dApp approval shape the page fixes no nonce, so two artifacts
// signed at different nonces both verify, and the approved transfer would go
// out twice.

const { Wallet } = require("ethers");

const SIGNER_KEY =
    "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";

const ORIGIN = "https://dapp.example";
const HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";

// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
// makes a duplicate broadcast possible at all.
const TX_PARAMS = {
    from: signer.address,
    to: RECIPIENT,
    value: "0x2386f26fc10000",
    data: "0x",
};

// The fields the popup's populateTransaction() would fill in. The nonce is a
// parameter because the duplicate case turns on the two artifacts differing
// in exactly the field nothing constrains.
function populated(nonce) {
    return {
        type: 2,
        chainId: 1,
        nonce,
        gasLimit: 100000n,
        maxFeePerGas: 2000000000n,
        maxPriorityFeePerGas: 1000000000n,
        to: TX_PARAMS.to,
        value: BigInt(TX_PARAMS.value),
        data: TX_PARAMS.data,
    };
}

function signedAtNonce(nonce) {
    return signer.signTransaction(populated(nonce));
}

// A promise whose settlement the test controls, so a broadcast can be held in
// flight while the second response arrives.
function deferred() {
    let resolve;
    let reject;
    const promise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
    });
    return { promise, resolve, reject };
}

// Load the background worker against stubbed browser and network APIs and
// return the handles the tests drive it through. Everything that would touch
// the network or the browser's own schedulers is mocked; the approval
// verification is the real module, because that is what the handler under
// test is wired to.
function loadBackground(options) {
    const opts = options || {};
    jest.resetModules();

    const broadcastTransaction = jest.fn();
    const loadState = jest.fn(opts.loadState || (async () => {}));

    jest.doMock("../src/shared/state", () => ({
        state: { rpcUrl: "https://rpc.invalid", wallets: [] },
        loadState,
        saveState: jest.fn(async () => {}),
        currentNetwork: () => ({ chainId: "0x1" }),
    }));
    jest.doMock("../src/shared/balances", () => ({
        getProvider: () => ({ broadcastTransaction }),
        refreshBalances: jest.fn(async () => {}),
    }));
    jest.doMock("../src/shared/phishingDomains", () => ({
        isPhishingDomain: () => false,
        refreshPhishingListOnSchedule: jest.fn(async () => {}),
        initPhishingList: jest.fn(async () => {}),
    }));
    jest.doMock("../src/shared/alarms", () => ({
        BALANCE_REFRESH_ALARM: "balance",
        PHISHING_REFRESH_ALARM: "phishing",
        BALANCE_REFRESH_PERIOD_MINUTES: 1,
        ensureRecurringAlarms: jest.fn(async () => {}),
        registerAlarmHandlers: jest.fn(),
    }));

    const persisted = {
        wallets: [
            { name: "Wallet 1", type: "hd", addresses: [signer.address] },
        ],
        rpcUrl: "https://rpc.invalid",
        activeAddress: signer.address,
        allowedSites: { [signer.address]: [HOSTNAME] },
        deniedSites: {},
    };

    let messageListener = null;
    let windowRemovedListener = null;
    const created = [];
    const removed = [];

    global.chrome = {
        storage: {
            local: {
                get: jest.fn(async () => ({ autistmask: persisted })),
                set: jest.fn(async () => {}),
            },
        },
        runtime: {
            getURL: (path) => EXT_URL + path,
            onMessage: {
                addListener: (fn) => {
                    messageListener = fn;
                },
            },
            onConnect: { addListener: () => {} },
            lastError: null,
        },
        windows: {
            getLastFocused: (cb) => cb(null),
            create: (options2, cb) => {
                created.push(options2);
                cb({ id: created.length });
            },
            remove: (id, cb) => {
                removed.push(id);
                if (cb) cb();
            },
            // Captured, not swallowed: closing the approval window is the
            // event that used to retire an approval out from under a live
            // broadcast, and a no-op stub here hides exactly that.
            onRemoved: {
                addListener: (fn) => {
                    windowRemovedListener = fn;
                },
            },
        },
        tabs: {
            query: (q, cb) => cb([]),
            sendMessage: () => {},
        },
        action: { setPopup: () => {} },
    };

    require("../src/background/index");

    // Send a message the way the browser would, and hand back whatever the
    // handler passed to sendResponse.
    function send(msg, sender) {
        const sendResponse = jest.fn();
        const kept = messageListener(msg, sender || {}, sendResponse);
        return { sendResponse, kept };
    }

    // Raise a pending transaction approval the way a dApp does, and dig the
    // approval id back out of the popup URL the background opened.
    function requestTx() {
        let rpcResult = null;
        const sendResponse = jest.fn((r) => {
            rpcResult = r;
        });
        messageListener(
            {
                type: "AUTISTMASK_RPC",
                method: "eth_sendTransaction",
                params: [TX_PARAMS],
            },
            { origin: ORIGIN },
            sendResponse,
        );
        return {
            id: () => new URL(created[0].url).searchParams.get("approval"),
            result: () => rpcResult,
        };
    }

    // The user closes the approval popup. `created` is index-aligned with the
    // ids the window stub hands back, so window 1 is the first popup opened.
    function closeWindow(windowId) {
        windowRemovedListener(windowId);
    }

    return {
        send,
        requestTx,
        closeWindow,
        broadcastTransaction,
        loadState,
        created,
        removed,
        fromPopup: { url: EXT_URL + "src/popup/index.html" },
    };
}

// Let the handler's promise chain run to the next suspension point.
async function settle() {
    for (let i = 0; i < 10; i++) await Promise.resolve();
}

afterEach(() => {
    delete global.chrome;
    jest.resetModules();
});

describe("one approval, one broadcast", () => {
    test("a second AUTISTMASK_TX_RESPONSE for the same id does not broadcast again", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();
        expect(id).toBeTruthy();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);

        // The popup answers. Verification passes and the broadcast is held
        // open, which is the whole window the second message arrives in.
        const first = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);

        // A reloaded approval window signs the same approval again. Nothing
        // in the approval fixes a nonce, so this artifact verifies just as
        // well as the first one.
        const second = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(8),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(second.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/already being sent/),
                retryable: false,
            }),
        );

        inFlight.resolve({ hash: "0xfeed" });
        await settle();
        expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
        expect(pending.result()).toEqual({ result: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    test("the same artifact sent twice broadcasts once", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        const raw = await signedAtNonce(7);
        const msg = {
            type: "AUTISTMASK_TX_RESPONSE",
            id,
            approved: true,
            rawSignedTx: raw,
        };

        bg.send(msg, { url: bg.fromPopup.url });
        bg.send(msg, { url: bg.fromPopup.url });
        await settle();
        inFlight.resolve({ hash: "0xfeed" });
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    test("a response arriving after the broadcast finished finds nothing to send", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        const late = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(8),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(late.sendResponse).not.toHaveBeenCalled();
    });

    test("a second AUTISTMASK_SIGN_RESPONSE for the same id is refused", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        // Hold the transaction approval in flight, then answer it a second
        // time as if it were a sign approval: the sign handler must apply the
        // same interlock rather than running its own verification.
        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        const second = bg.send(
            {
                type: "AUTISTMASK_SIGN_RESPONSE",
                id,
                approved: true,
                signature: "0x00",
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(second.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/already being signed/),
                retryable: false,
            }),
        );
        inFlight.resolve({ hash: "0xfeed" });
        await settle();
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });
});

// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
    test("a retryable failure before the broadcast leaves the approval usable", async () => {
        let failNext = true;
        const bg = loadBackground({
            loadState: async () => {
                if (failNext) {
                    failNext = false;
                    throw new Error("storage unavailable");
                }
            },
        });
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const first = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(first.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({ retryable: true }),
        );

        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        const retry = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(retry.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
        expect(pending.result()).toEqual({ result: "0xfeed" });
    });

    test("a mismatched artifact spends the approval outright", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        // Signed for a different recipient than the one that was approved.
        const wrong = await signer.signTransaction({
            ...populated(7),
            to: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
        });
        const first = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: wrong,
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(first.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({ retryable: false, stage: "verify" }),
        );

        const retry = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(retry.sendResponse).not.toHaveBeenCalled();
    });
});

// The claim is what makes one approval one broadcast, so it has to hold
// against everything else that retires an approval, not just against a second
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
// promise 4001 while the attempt behind it ran to completion: the transaction
// reached the chain and the page was told the user rejected it, which invites
// the user to send it a second time at a fresh nonce.
describe("a claimed approval outlives every other retirement path", () => {
    // The approval popup stays open across the broadcast it is waiting on, so
    // a user closing an apparently-hung window needs no adversary at all.
    test("closing the approval window mid-broadcast still reports the result", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        const first = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);

        // The user closes the window while the broadcast is still open.
        bg.closeWindow(1);
        await settle();
        expect(pending.result()).toBeNull();

        inFlight.resolve({ hash: "0xfeed" });
        await settle();

        expect(pending.result()).toEqual({ result: "0xfeed" });
        expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    test("switching the active address mid-broadcast still reports the result", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);

        // The user switches account in the toolbar popup, which rejects and
        // force-closes every pending approval.
        bg.send(
            { type: "AUTISTMASK_ACTIVE_CHANGED" },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(pending.result()).toBeNull();
        // The window an in-flight attempt reports into is left standing too.
        expect(bg.removed).toEqual([]);

        inFlight.resolve({ hash: "0xfeed" });
        await settle();

        expect(pending.result()).toEqual({ result: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    test("a reject arriving mid-broadcast is refused, not honoured", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        const reject = bg.send(
            { type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(pending.result()).toBeNull();
        expect(reject.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                retryable: false,
                stage: "broadcast",
            }),
        );

        inFlight.resolve({ hash: "0xfeed" });
        await settle();

        expect(pending.result()).toEqual({ result: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    // The refusals above must not cost the rejection its ordinary meaning.
    test("with no attempt running, closing the window still rejects", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();

        bg.closeWindow(1);
        await settle();

        expect(pending.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });
        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
    });

    test("with no attempt running, an active-address switch still rejects and closes", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();

        bg.send(
            { type: "AUTISTMASK_ACTIVE_CHANGED" },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(pending.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });
        expect(bg.removed).toEqual([1]);
    });

    // A sign approval held by a running verification is the same shape, and
    // the refusal must not tell the user to start again from the site while
    // the first attempt may still hand back a signature.
    test("a reject during a sign attempt is refused with the in-flight stage", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        const reject = bg.send(
            { type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(reject.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({ retryable: false, stage: "inflight" }),
        );

        inFlight.resolve({ hash: "0xfeed" });
        await settle();
        expect(pending.result()).toEqual({ result: "0xfeed" });
    });
});

describe("popup-only messages", () => {
    test("a page sender cannot answer an approval", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const spoof = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: ORIGIN + "/index.html" },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(spoof.sendResponse).toHaveBeenCalledWith({
            error: "Unauthorized sender",
        });
    });
});
