// 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: the
// same approved transaction signed twice verifies twice, and the transfer
// would go out twice.
//
// It also covers what the approval is verified against. The approval now
// carries the transaction the background populated and the screen displayed,
// and the address that was active when it was raised — so a fee, a nonce or an
// address that moved between approval and signing is refused rather than
// signed.

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

// The real formatter the approval screen renders failures through. Bound here,
// before any jest.doMock() of the module, so the copy assertions below check
// what the user is actually shown.
const { describeSigningFailure } = require("../src/shared/approvalVerify");
const { makeStorageStub } = require("./support/storageStub");

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

const ORIGIN = "https://dapp.example";
const HOSTNAME = "dapp.example";
// A page the wallet has never been connected to, whose requests are refused.
const UNCONNECTED_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";

// An origin the persisted state has never allowed, so asking to connect from
// it raises a prompt rather than being answered from allowedSites.
const FRESH_ORIGIN = "https://fresh.example";

// The approval id in the most recent popup URL of a list, or null when none
// of them carries one. Takes both shapes: the absolute URL windows.create()
// is given and the extension-relative one action.setPopup() is given.
function approvalIdIn(urls) {
    for (let i = urls.length - 1; i >= 0; i--) {
        if (!urls[i] || !urls[i].includes("?approval=")) continue;
        return new URL(urls[i], EXT_URL).searchParams.get("approval");
    }
    return null;
}

// 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 nonce the stubbed node reports, and so the nonce the background
// populates the approval with.
const NONCE = 7;

// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
const MESSAGE = "0x48656c6c6f204175746973744d61736b";

// The transaction the background populates and the approval screen displays.
// The nonce is a parameter because the duplicate case turns on two artifacts
// differing in a field the dApp fixed nothing for.
// The two chains the tests switch between, as both forms the code uses: the
// hex chain id the wallet's network record carries, and the number the node
// and the signed artifact carry.
const MAINNET = { hex: "0x1", num: 1 };
const SEPOLIA = { hex: "0xaa36a7", num: 11155111 };

function populated(nonce, chainId) {
    return {
        type: 2,
        chainId: chainId || MAINNET.num,
        nonce,
        gasLimit: 100000n,
        maxFeePerGas: 2000000000n,
        maxPriorityFeePerGas: 1000000000n,
        to: TX_PARAMS.to,
        value: BigInt(TX_PARAMS.value),
        data: TX_PARAMS.data,
    };
}

function signedAtNonce(nonce, withWallet, chainId) {
    return (withWallet || signer).signTransaction(populated(nonce, chainId));
}

// The node the background populates against. Its answers are the numbers the
// approval screen shows, so they are also the numbers every artifact below is
// signed at.
function fakeProvider(broadcastTransaction, overrides, chainId) {
    return {
        broadcastTransaction,
        getNetwork: async () => Network.from(chainId || MAINNET.num),
        getTransactionCount: async () => NONCE,
        estimateGas: async () => 100000n,
        getFeeData: async () => ({
            gasPrice: 2000000000n,
            maxFeePerGas: 2000000000n,
            maxPriorityFeePerGas: 1000000000n,
        }),
        ...(overrides || {}),
    };
}

// 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();

    // The node the transaction is populated against is on whatever chain the
    // stored profile says, as it would be: switching networks switches the RPC
    // endpoint too. The background takes the network from storage per call —
    // it holds no in-memory copy — so this reads the record rather than a
    // variable the test keeps alongside it.
    const chainOf = (networkId) =>
        networkId === "sepolia" ? SEPOLIA : MAINNET;

    jest.doMock("../src/shared/balances", () => ({
        getProvider: (rpcUrl, networkId) =>
            fakeProvider(
                broadcastTransaction,
                opts.provider,
                chainOf(networkId).num,
            ),
        refreshBalances: jest.fn(async () => {}),
    }));
    jest.doMock("../src/shared/phishingDomains", () => ({
        isPhishingDomain: () => false,
    }));
    jest.doMock("../src/shared/alarms", () => ({
        BALANCE_REFRESH_ALARM: "balance",
        BALANCE_REFRESH_PERIOD_MINUTES: 1,
        ensureRecurringAlarms: jest.fn(async () => {}),
        registerAlarmHandlers: jest.fn(),
    }));
    // The real verification module, except where a test replaces one export
    // with a throw to drive the handler's own error handling into failing.
    if (opts.approvalVerify) {
        jest.doMock("../src/shared/approvalVerify", () => ({
            ...jest.requireActual("../src/shared/approvalVerify"),
            ...opts.approvalVerify,
        }));
    }

    const persisted = {
        // Address RECORDS, not bare address strings: a stored profile is
        // validated against the schema on every read now
        // (src/shared/stateSchema.js), and a bare string where a record
        // belongs is one of the shapes that refuses to load.
        wallets: [
            {
                name: "Wallet 1",
                type: "hd",
                addresses: [
                    {
                        address: signer.address,
                        balance: "0",
                        tokenBalances: [],
                    },
                ],
            },
        ],
        networkId: "mainnet",
        rpcUrl: "https://rpc.invalid",
        activeAddress: signer.address,
        allowedSites: { [signer.address]: [HOSTNAME] },
        deniedSites: {},
    };

    // The one wallet state there is. The background reads it per call and
    // writes it read-modify-write; it holds no in-memory copy and cannot reach
    // the shared singleton. Clones in both directions, as the real API does —
    // the stub here used to hand back the live record and drop every write on
    // the floor, so a test could neither see what was persisted nor be sure
    // what it read had crossed the boundary
    // (https://git.eeqj.de/sneak/AutistMask/issues/324).
    const storage = makeStorageStub({ autistmask: persisted });

    // A test that needs the state read itself to misbehave installs a hook —
    // a stall, a throw — in place of the next reads. Armed after setup so
    // that raising the approval is not what fails.
    let storageGetHook = opts.storageGet || null;
    const realGet = storage.local.get;
    storage.local.get = jest.fn(async (key) =>
        storageGetHook ? storageGetHook(key) : realGet(key),
    );

    let messageListener = null;
    let windowRemovedListener = null;
    let connectListener = null;
    const created = [];
    const removed = [];
    // Every URL the background put on the browser action. A site approval
    // raised through action.openPopup() opens no window at all, so this is
    // the only place its id appears.
    const actionPopups = [];

    global.chrome = {
        storage,
        runtime: {
            getURL: (path) => EXT_URL + path,
            onMessage: {
                addListener: (fn) => {
                    messageListener = fn;
                },
            },
            // Captured, not swallowed: the approval port is what carries a
            // site connection's decision and the popup teardown that races
            // it, so a no-op stub here hides the whole subject of #275.
            onConnect: {
                addListener: (fn) => {
                    connectListener = fn;
                },
            },
            lastError: null,
        },
        windows: {
            getLastFocused: (cb) => cb(null),
            create: (options2, cb) => {
                created.push(options2);
                // A browser that answers with no window at all. The approval
                // then has no window it can ever be answered in.
                cb(opts.noWindow ? undefined : { 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: (o) => {
                actionPopups.push(o.popup);
            },
            // The production route for a site connection. Present only when
            // a test asks for it, because with it the prompt is the toolbar
            // popup: no window is created, so windows.onRemoved can never
            // fire for it and the port disconnect is the only close signal
            // that exists.
            ...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
        },
    };

    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(txParams, origin) {
        let rpcResult = null;
        // The window this request opens, if it opens one. A request refused
        // before an approval is raised opens none, and the window belonging to
        // some other request must not be handed back as this one's.
        const windowIndex = created.length;
        const sendResponse = jest.fn((r) => {
            rpcResult = r;
        });
        messageListener(
            {
                type: "AUTISTMASK_RPC",
                method: "eth_sendTransaction",
                params: [txParams || TX_PARAMS],
            },
            { origin: origin || ORIGIN },
            sendResponse,
        );
        return {
            id: () =>
                created.length > windowIndex
                    ? new URL(created[windowIndex].url).searchParams.get(
                          "approval",
                      )
                    : null,
            result: () => rpcResult,
        };
    }

    // The same for a message-signing approval, which pins the signing address
    // at approval time in exactly the same way.
    function requestSign(from) {
        let rpcResult = null;
        messageListener(
            {
                type: "AUTISTMASK_RPC",
                method: "personal_sign",
                params: [MESSAGE, from || signer.address],
            },
            { origin: ORIGIN },
            (r) => {
                rpcResult = r;
            },
        );
        return {
            id: () =>
                new URL(created[created.length - 1].url).searchParams.get(
                    "approval",
                ),
            result: () => rpcResult,
        };
    }

    // A dApp asking to connect. The origin defaults to one the persisted
    // state has never allowed, so the request really does raise a prompt
    // instead of being answered from allowedSites.
    function requestSite(origin) {
        let rpcResult = null;
        messageListener(
            {
                type: "AUTISTMASK_RPC",
                method: "eth_requestAccounts",
                params: [],
            },
            { origin: origin || FRESH_ORIGIN },
            (r) => {
                rpcResult = r;
            },
        );
        return {
            // Wherever the prompt went: the toolbar popup URL when
            // action.openPopup() carried it, the created window otherwise.
            id: () =>
                approvalIdIn(actionPopups) ||
                approvalIdIn(created.map((c) => c.url)),
            result: () => rpcResult,
        };
    }

    // The popup's approval port, as the browser delivers it. Messages posted
    // on a port and that port's disconnect travel one channel in FIFO order,
    // which is exactly the property the fix rests on, so this stub delivers
    // them in the order the caller emits them and never reorders them.
    function connectApproval(id, senderUrl) {
        const onMessage = [];
        const onDisconnect = [];
        const port = {
            name: "approval:" + id,
            sender: {
                url:
                    senderUrl === undefined
                        ? EXT_URL + "src/popup/index.html?approval=" + id
                        : senderUrl,
            },
            onMessage: { addListener: (fn) => onMessage.push(fn) },
            onDisconnect: { addListener: (fn) => onDisconnect.push(fn) },
        };
        connectListener(port);
        return {
            decide: (approved, remember) => {
                for (const fn of onMessage) {
                    fn(
                        {
                            type: "AUTISTMASK_APPROVAL_DECISION",
                            approved,
                            remember: !!remember,
                        },
                        port,
                    );
                }
            },
            disconnect: () => {
                for (const fn of onDisconnect) fn(port);
            },
        };
    }

    // 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,
        requestSign,
        requestSite,
        connectApproval,
        closeWindow,
        broadcastTransaction,
        created,
        removed,
        storage,
        // The user switching account in the toolbar popup, as the background
        // sees it: the persisted active address changes underneath a pending
        // approval.
        setActiveAddress: (address) => {
            storage.write("autistmask", {
                ...storage.read("autistmask"),
                activeAddress: address,
            });
        },
        // The user switching network in the toolbar popup. It moves the stored
        // network and the endpoint together, as a real switch does.
        setNetwork: (network) => {
            const networkId = network === SEPOLIA ? "sepolia" : "mainnet";
            storage.write("autistmask", {
                ...storage.read("autistmask"),
                networkId,
                rpcUrl: "https://rpc-" + networkId + ".invalid",
            });
        },
        // Make the next state reads misbehave — stall, throw — without
        // touching the reads that raised the approval. Pass null to restore.
        setStateReadHook: (hook) => {
            storageGetHook = hook;
        },
        fromPopup: { url: EXT_URL + "src/popup/index.html" },
    };
}

// Let the handler's promise chain run to the next suspension point. Raising a
// transaction approval now populates it against the node first, which is
// several awaits deep before the window is opened.
async function settle() {
    for (let i = 0; i < 50; i++) await Promise.resolve();
}

// settle() only drains microtasks. A handler whose last-resort .catch() has to
// run after a macrotask boundary needs those turns too, so the assertion that
// the page WAS answered is what reports a regression rather than a timeout.
async function settleIncludingRejections() {
    await settle();
    await new Promise((resolve) => setImmediate(resolve));
    await new Promise((resolve) => setImmediate(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, at another
        // nonce. The claim is taken before anything is verified, so what this
        // asserts is the interlock and not the nonce comparison: the refusal
        // below is the claim's own message, which a verification failure does
        // not produce.
        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);
    });
});

// Populating the transaction before the approval window opens is what makes
// the displayed object the verified object. It also fixes the nonce before the
// user has answered anything: two requests populated concurrently take the
// same nonce from a node that has seen neither of them broadcast, and the
// second can then never be sent, because the only way to give it a fresh nonce
// is to populate it again after the user has read the old one off the screen.
// So the second request is refused while the first is unanswered.
describe("one transaction approval at a time", () => {
    test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
        const getTransactionCount = jest.fn(async () => NONCE);
        const bg = loadBackground({ provider: { getTransactionCount } });

        const first = bg.requestTx();
        await settle();
        expect(first.id()).toBeTruthy();
        expect(getTransactionCount).toHaveBeenCalledTimes(1);

        const second = bg.requestTx();
        await settle();

        expect(second.result()).toEqual({
            error: {
                code: -32002,
                message: expect.stringMatching(
                    /one transaction at a time.+already in progress/,
                ),
            },
        });
        // Where the refusal happened matters as much as that it happened: no
        // second window, and the node was never asked for a second nonce.
        expect(bg.created).toHaveLength(1);
        expect(getTransactionCount).toHaveBeenCalledTimes(1);

        // The refusal leaves the pending approval untouched, and it still
        // sends.
        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: first.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(first.result()).toEqual({ result: "0xfeed" });
    });

    // The slot is only defensible if the wallet was going to raise an approval
    // anyway. Taken any earlier, a request the wallet refuses outright still
    // holds it, and any page at all — connected or not — can deny the user's
    // own transactions for as long as it keeps asking.
    test("a request the wallet refuses does not take the slot from the connected site", async () => {
        const bg = loadBackground();

        // Both delivered before either reaches its first suspension point,
        // which is the interleaving the slot exists for.
        const stranger = bg.requestTx(TX_PARAMS, UNCONNECTED_ORIGIN);
        const connected = bg.requestTx();
        await settle();

        expect(stranger.result()).toEqual({
            error: { code: 4100, message: "Unauthorized" },
        });
        // The connected site's transaction was raised, not refused as one the
        // user already has in progress.
        expect(connected.result()).toBeNull();
        expect(connected.id()).toBeTruthy();
        expect(bg.created).toHaveLength(1);
    });

    // The user closes an approval window that looks hung while the attempt
    // behind it is still running, and that attempt then fails in a way that
    // would normally leave the approval standing for a retry. There is no
    // window left to retry in, so leaving it standing answers the requesting
    // page never — and holds the slot for the life of the worker with it.
    test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
        const stalled = deferred();
        const bg = loadBackground();

        const first = bg.requestTx();
        await settle();
        // Armed only now: the approval was raised against a working state
        // read, and it is the ATTEMPT's read that hangs and then fails.
        bg.setStateReadHook(async () => {
            await stalled.promise;
            throw new Error("The wallet data could not be read.");
        });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: first.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        // The attempt owns the approval, so closing the window does not settle
        // it: the attempt may yet broadcast, and it is the one that reports.
        bg.closeWindow(1);
        await settle();
        expect(first.result()).toBeNull();

        stalled.resolve();
        await settle();
        expect(first.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });

        bg.setStateReadHook(null);
        const second = bg.requestTx();
        await settle();
        expect(second.result()).toBeNull();
        expect(second.id()).toBeTruthy();
        expect(bg.created).toHaveLength(2);
    });

    // An approval with no window is one nothing can ever answer.
    test("a request whose approval window cannot be opened is answered rather than left waiting", async () => {
        const bg = loadBackground({ noWindow: true });

        const first = bg.requestTx();
        await settle();
        expect(first.result()).toEqual({
            error: {
                code: -32603,
                message: expect.stringMatching(
                    /could not open its approval window/,
                ),
            },
        });

        // And it did not take the slot with it.
        const second = bg.requestTx();
        await settle();
        expect(second.result()).toEqual({
            error: {
                code: -32603,
                message: expect.stringMatching(
                    /could not open its approval window/,
                ),
            },
        });
    });

    test("an answered approval frees the next request", async () => {
        const bg = loadBackground();
        const first = bg.requestTx();
        await settle();

        // The user closes the approval window, which rejects it.
        bg.closeWindow(1);
        await settle();
        expect(first.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });

        const second = bg.requestTx();
        await settle();
        expect(second.id()).toBeTruthy();
        expect(bg.created).toHaveLength(2);
    });

    test("a signature request is not held up by a pending transaction", async () => {
        const bg = loadBackground();
        bg.requestTx();
        await settle();

        // A signature consumes no nonce, so it has nothing to collide with.
        const signing = bg.requestSign();
        await settle();
        expect(signing.id()).toBeTruthy();
        expect(signing.result()).toBeNull();
        expect(bg.created).toHaveLength(2);
    });
});

// A nonce collision found before the transaction reaches the network is the
// one send failure the wallet can speak about with certainty. The user is told
// it did not go out and to send it again, rather than being warned it might
// already be on the chain — which would send them looking for a transaction
// that does not exist, and stop them retrying the one that never went.
describe("a nonce collision is reported as a transaction that did not go out", () => {
    test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();

        bg.broadcastTransaction.mockRejectedValue(
            Object.assign(new Error("nonce too low"), {
                code: "NONCE_EXPIRED",
            }),
        );
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: pending.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(answer.sendResponse).toHaveBeenCalledWith({
            error: expect.stringMatching(/nonce had already been used/),
            retryable: false,
            stage: "nonce",
        });
        expect(pending.result()).toEqual({
            error: {
                message: expect.stringMatching(
                    /transaction was not sent, because its nonce/,
                ),
            },
        });
    });

    test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
        const bg = loadBackground();
        const first = bg.requestTx();
        await settle();

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

        // The stubbed node still reports NONCE as the next nonce — a pending
        // count that lags a broadcast the node has already taken — so this
        // second approval is populated at a nonce this worker has spent.
        const second = bg.requestTx();
        await settle();
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: second.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(answer.sendResponse).toHaveBeenCalledWith({
            error: expect.stringMatching(/nonce had already been used/),
            retryable: false,
            stage: "nonce",
        });
        expect(second.result()).toEqual({
            error: {
                message: expect.stringMatching(/nonce had already been used/),
            },
        });
    });

    // Nonce spaces are per chain, and the wallet switches networks. A nonce
    // this wallet spent on one chain says nothing about the same nonce on
    // another — and low nonces overlap across chains as a matter of course, so
    // a record that ignored the chain would refuse ordinary transactions,
    // permanently and with a message that is not true of them.
    test("a nonce spent on one chain is not refused on another", async () => {
        const bg = loadBackground();

        const first = bg.requestTx();
        await settle();
        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: first.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(first.result()).toEqual({ result: "0xfeed" });

        // The user switches network. On this chain the address has sent
        // nothing, so the node populates the next transaction at the same
        // nonce — correctly.
        bg.setNetwork(SEPOLIA);
        const second = bg.requestTx();
        await settle();
        bg.broadcastTransaction.mockResolvedValue({ hash: "0xbeef" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: second.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE, undefined, SEPOLIA.num),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(2);
        expect(second.result()).toEqual({ result: "0xbeef" });
    });
});

// The approval carries the transaction the user was shown and the address it
// was raised for, and the artifact is checked against both. Every case here is
// one the old comparison — against the dApp's request, for the address that is
// active now — would have broadcast.
describe("what the approval is verified against", () => {
    // The approval screen showed the populated fee. An artifact at ten times
    // that fee, still far below the ceilings, is what the ceilings alone could
    // not catch.
    test("a fee differing from the displayed one is refused, not sent", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const raw = await signer.signTransaction({
            ...populated(NONCE),
            maxFeePerGas: 20000000000n,
        });
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: raw,
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/approved maximum fee per gas/),
                retryable: false,
                stage: "verify",
            }),
        );
        expect(pending.result()).toEqual({
            error: {
                message: expect.stringMatching(/approved maximum fee per gas/),
            },
        });
    });

    test("a nonce differing from the displayed one is refused, not sent", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

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

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/approved nonce/),
                retryable: false,
                stage: "verify",
            }),
        );
    });

    // The address switch. The approval named one account; the wallet is on
    // another by the time the artifact arrives. Both halves are covered: the
    // popup signing as the account that is active now, and the popup correctly
    // signing as the approved account while the wallet has moved on.
    test("an artifact signed by the address that is active now is refused", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        bg.setActiveAddress(other.address);
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE, other),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({ retryable: false, stage: "verify" }),
        );
        expect(pending.result()).toEqual({
            error: { message: expect.stringMatching(/active address changed/) },
        });
    });

    test("an address switch refuses even the correctly signed artifact", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        bg.setActiveAddress(other.address);
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/active address changed/),
                retryable: false,
                stage: "verify",
            }),
        );
        // A refusal, so the approval is spent: the same artifact offered again
        // finds nothing to answer.
        const retry = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(retry.sendResponse).not.toHaveBeenCalled();
        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
    });

    test("a switch back to the approved address still sends", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

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

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

    // The popup is handed the populated transaction and the address it is for,
    // and nothing else it would have to fetch or decide.
    test("the popup is given the transaction it is to sign", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();

        const details = bg.send(
            { type: "AUTISTMASK_GET_APPROVAL", id: pending.id() },
            { url: bg.fromPopup.url },
        );
        const shown = details.sendResponse.mock.calls[0][0];
        expect(shown.type).toBe("tx");
        expect(shown.approvedFrom).toBe(signer.address);
        expect(shown.approvedTx).toEqual({
            type: 2,
            from: signer.address,
            chainId: "0x1",
            nonce: "0x7",
            gasLimit: "0x186a0",
            maxFeePerGas: "0x77359400",
            maxPriorityFeePerGas: "0x3b9aca00",
            to: RECIPIENT,
            value: TX_PARAMS.value,
            data: "0x",
            accessList: [],
        });
    });

    // A request naming an account the wallet is not on is refused outright
    // rather than signed as whichever account is active.
    test("a request from another address raises no approval at all", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx({ ...TX_PARAMS, from: other.address });
        await settle();

        expect(pending.result()).toEqual({
            error: {
                code: 4100,
                message: expect.stringMatching(/not the active one/),
            },
        });
        expect(bg.created).toEqual([]);
    });

    // Message signing pins the address the same way, and refuses the same way.
    // A signature is not a transaction, but a permit signed by an account the
    // approval did not name spends that account's tokens all the same.
    test("a sign approval refuses a signature after an address switch", async () => {
        const bg = loadBackground();
        const pending = bg.requestSign();
        await settle();

        bg.setActiveAddress(other.address);
        const answer = bg.send(
            {
                type: "AUTISTMASK_SIGN_RESPONSE",
                id: pending.id(),
                approved: true,
                signature: await signer.signMessage(
                    Buffer.from(MESSAGE.slice(2), "hex"),
                ),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/active address changed/),
                retryable: false,
            }),
        );
        expect(pending.result()).toEqual({
            error: { message: expect.stringMatching(/active address changed/) },
        });
    });

    test("a sign request from another address raises no approval at all", async () => {
        const bg = loadBackground();
        const pending = bg.requestSign(other.address);
        await settle();

        expect(pending.result()).toEqual({
            error: {
                code: 4100,
                message: expect.stringMatching(/not the active one/),
            },
        });
        expect(bg.created).toEqual([]);
    });

    // Population is a network round trip with the user's hands free. An
    // approval raised for the address that was active when it started could
    // never be signed once the wallet has moved off it, so it is never raised.
    test("an address switch during population raises no approval", async () => {
        let bg;
        bg = loadBackground({
            provider: {
                // The user switches account in the toolbar popup while the
                // node is being asked for a gas estimate.
                estimateGas: async () => {
                    bg.setActiveAddress(other.address);
                    return 100000n;
                },
            },
        });
        const pending = bg.requestTx();
        await settle();

        expect(pending.result()).toEqual({
            error: {
                message: expect.stringMatching(
                    /active address changed while this transaction was being prepared/,
                ),
            },
        });
        expect(bg.created).toEqual([]);
    });

    // Population happens before the window exists, so its failure is a failure
    // of the request: no approval, no window, and the error goes back to the
    // page the click came from.
    test("a transaction that cannot be prepared opens no window", async () => {
        const bg = loadBackground({
            provider: {
                estimateGas: async () => {
                    throw new Error("execution reverted");
                },
            },
        });
        const pending = bg.requestTx();
        await settle();

        expect(pending.result()).toEqual({
            error: {
                message: expect.stringMatching(
                    /could not be prepared.*execution reverted/,
                ),
            },
        });
        expect(bg.created).toEqual([]);
    });
});

// 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 () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        // The attempt's state read fails once, then works: nothing was
        // broadcast, so the approval must survive for the retry.
        bg.setStateReadHook(() => {
            bg.setStateReadHook(null);
            throw new Error("storage unavailable");
        });

        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" });
    });
});

// A handler that throws must still answer. `sendResponse` is the only thing
// that settles the page's window.ethereum.request() promise, so a throw that
// escapes a handler leaves that promise pending forever — no error, no
// timeout, indistinguishable from a slow wallet. Each case below drives a real
// throw out of a handler rather than asserting the catch block exists.
describe("a handler that throws still settles the page", () => {
    const INTERNAL_ERROR = {
        code: -32603,
        message:
            "AutistMask could not complete this request because of an internal error.",
    };

    let errorLog;
    beforeEach(() => {
        errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
    });
    afterEach(() => {
        errorLog.mockRestore();
    });

    // getState() awaits extension storage unguarded, and every read path in
    // handleRpc goes through it. A storage read that rejects is the whole
    // failure — no hook in the handler itself.
    test("a rejected handleRpc rejects the page instead of hanging it", async () => {
        const bg = loadBackground({
            storageGet: async () => {
                throw new Error("storage unavailable");
            },
        });

        const answer = bg.send(
            { type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
            { origin: ORIGIN },
        );
        await settleIncludingRejections();

        // The channel is held open for the async answer, and the answer
        // arrives.
        expect(answer.kept).toBe(true);
        expect(answer.sendResponse).toHaveBeenCalledWith({
            error: INTERNAL_ERROR,
        });
        // Not swallowed: the throw is on the background console, which is how
        // this class gets caught in future.
        expect(errorLog).toHaveBeenCalledWith(
            "[AutistMask]",
            "RPC request failed:",
            "eth_accounts",
            expect.objectContaining({ message: "storage unavailable" }),
        );
    });

    // The transaction response handler wraps every statement in a try, so what
    // escapes it is a throw from inside one of its catch blocks. Here the
    // failure classifier itself throws while classifying a real verification
    // failure — the approval is left claimed, so nothing else can settle it.
    // The escape happens before broadcastTransaction() is reached, so the
    // reported stage must be the one that says the transaction is gone.
    test("a throw while verifying a transaction settles both the page and the popup", async () => {
        const bg = loadBackground({
            approvalVerify: {
                describeTxFailure: () => {
                    throw new Error("classifier broke");
                },
            },
        });
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        // A real verification failure: the artifact is signed at a nonce the
        // approval never displayed.
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE + 1),
            },
            { url: bg.fromPopup.url },
        );
        await settleIncludingRejections();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
        expect(answer.sendResponse).toHaveBeenCalledWith({
            error: INTERNAL_ERROR.message,
            retryable: false,
            // Nothing was broadcast, so the popup must say the request is gone
            // rather than that it may still have reached the network.
            stage: "verify",
        });
        expect(errorLog).toHaveBeenCalledWith(
            "[AutistMask]",
            "transaction approval response failed:",
            expect.objectContaining({ message: "classifier broke" }),
        );
        // The copy the user actually reads, from the popup's own formatter.
        expect(
            describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
                .message,
        ).toBe(
            INTERNAL_ERROR.message +
                " This request can no longer be signed." +
                " Please start it again from the site.",
        );
    });

    // The other side of the same local: once broadcastTransaction() has been
    // entered the wallet genuinely cannot tell whether the node took the
    // transaction, and the copy that warns about a second send is correct.
    test("a throw while handling a failed broadcast reports the broadcast stage", async () => {
        const bg = loadBackground({
            approvalVerify: {
                describeTxFailure: () => {
                    throw new Error("classifier broke");
                },
            },
        });
        bg.broadcastTransaction.mockRejectedValue(new Error("node refused"));
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        // The approved artifact, so verification passes and the failure
        // happens at the broadcast.
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settleIncludingRejections();

        expect(bg.broadcastTransaction).toHaveBeenCalled();
        expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
        expect(answer.sendResponse).toHaveBeenCalledWith({
            error: INTERNAL_ERROR.message,
            retryable: false,
            stage: "broadcast",
        });
        expect(
            describeSigningFailure(answer.sendResponse.mock.calls[0][0], "")
                .message,
        ).toBe(
            INTERNAL_ERROR.message +
                " The transaction may still have reached the network." +
                " Check the account before sending it again.",
        );
    });

    test("a throw while handling a failed signature settles both the page and the popup", async () => {
        const bg = loadBackground({
            approvalVerify: {
                failureIsRetryable: () => {
                    throw new Error("classifier broke");
                },
            },
        });
        const pending = bg.requestSign();
        await settle();

        // A real verification failure: the active address moved after the
        // approval was raised.
        bg.setActiveAddress(other.address);
        const answer = bg.send(
            {
                type: "AUTISTMASK_SIGN_RESPONSE",
                id: pending.id(),
                approved: true,
                signature: await signer.signMessage(
                    Buffer.from(MESSAGE.slice(2), "hex"),
                ),
            },
            { url: bg.fromPopup.url },
        );
        await settleIncludingRejections();

        expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
        expect(answer.sendResponse).toHaveBeenCalledWith({
            error: INTERNAL_ERROR.message,
            retryable: false,
        });
        expect(errorLog).toHaveBeenCalledWith(
            "[AutistMask]",
            "sign approval response failed:",
            expect.objectContaining({ message: "classifier broke" }),
        );
    });
});

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",
        });
    });
});

// A site connection decided in a popup that closes on the next line.
//
// The decision and the teardown are two events the popup emits back to back,
// and the background must not be able to reach different outcomes depending on
// which of them it processes first. It cannot, because they are now one
// channel: the decision is posted on the approval port that the close then
// disconnects, so it is delivered first. Every test here therefore emits the
// close IMMEDIATELY after the decision, with nothing awaited in between —
// which is what the popup does, and what used to report a user who approved as
// having refused (#275).
describe("a site connection decided as the popup closes", () => {
    // The production route: chrome.action.openPopup() put the prompt in the
    // toolbar popup, which is not a window, so nothing but the port
    // disconnect can tell the background this prompt is gone.
    test("approving in the toolbar popup connects the site", async () => {
        const bg = loadBackground({ actionPopup: true });
        const pending = bg.requestSite();
        await settle();
        const id = pending.id();
        expect(id).toBeTruthy();
        expect(bg.created).toHaveLength(0);

        const port = bg.connectApproval(id);
        port.decide(true, false);
        port.disconnect();
        await settle();

        expect(pending.result()).toEqual({ result: [signer.address] });
    });

    test("closing the toolbar popup without deciding is a rejection", async () => {
        const bg = loadBackground({ actionPopup: true });
        const pending = bg.requestSite();
        await settle();

        const port = bg.connectApproval(pending.id());
        port.disconnect();
        await settle();

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

    test("rejecting is a rejection, and the close that follows adds nothing", async () => {
        const bg = loadBackground({ actionPopup: true });
        const pending = bg.requestSite();
        await settle();

        const port = bg.connectApproval(pending.id());
        port.decide(false, false);
        port.disconnect();
        await settle();

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

    // The port carries a decision now, so it carries the sender check the
    // one-off message used to carry. A content script that guessed an
    // approval id must not be able to connect the site it is running on.
    test("a decision from a page sender is ignored, and the close rejects", async () => {
        const bg = loadBackground({ actionPopup: true });
        const pending = bg.requestSite();
        await settle();

        const port = bg.connectApproval(pending.id(), FRESH_ORIGIN + "/x.html");
        port.decide(true, true);
        await settle();
        expect(pending.result()).toBeNull();

        port.disconnect();
        await settle();
        expect(pending.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });
    });

    // The fallback shape, where openPopup() is unavailable and the prompt is
    // a window the extension opened. Closing it fires windows.onRemoved as
    // well, on a channel of its own that is ordered against nothing — so the
    // window event must not be allowed to decide a site approval either.
    //
    // The event goes FIRST here, which is the interleaving the guard in the
    // onRemoved listener exists for: the approval is still pending when the
    // event arrives, so the listener really reaches it and really has to
    // decline it. With the decision first there is nothing left in
    // pendingApprovals and the listener finds no approval to spare.
    test("approving in the fallback window survives a window event that lands first", async () => {
        const bg = loadBackground();
        const pending = bg.requestSite();
        await settle();
        expect(bg.created).toHaveLength(1);

        const port = bg.connectApproval(pending.id());
        bg.closeWindow(1);
        port.decide(true, false);
        port.disconnect();
        await settle();

        expect(pending.result()).toEqual({ result: [signer.address] });
    });

    test("approving in the fallback window survives a window event that follows", async () => {
        const bg = loadBackground();
        const pending = bg.requestSite();
        await settle();

        const port = bg.connectApproval(pending.id());
        port.decide(true, false);
        bg.closeWindow(1);
        port.disconnect();
        await settle();

        expect(pending.result()).toEqual({ result: [signer.address] });
    });

    // The connected port is what silences the window event, so connecting one
    // must take the same sender check the decision takes. Otherwise a content
    // script that guessed the id switches off the only settlement path a
    // prompt whose real popup never connected has, and the dApp hangs.
    test("a port from a page sender does not silence the window event", async () => {
        const bg = loadBackground();
        const pending = bg.requestSite();
        await settle();

        // Connected and held open — no disconnect, so nothing but the window
        // event can settle this approval.
        bg.connectApproval(pending.id(), FRESH_ORIGIN + "/x.html");
        bg.closeWindow(1);
        await settle();

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

    // Same shape, and the same window event arriving before the popup has
    // said anything at all — which is a user closing the window rather than
    // deciding, and still has to reach the dApp as a rejection.
    test("closing the fallback window without deciding is a rejection", async () => {
        const bg = loadBackground();
        const pending = bg.requestSite();
        await settle();

        const port = bg.connectApproval(pending.id());
        bg.closeWindow(1);
        port.disconnect();
        await settle();

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

    // The net under the paragraph above: a prompt whose page never got as far
    // as connecting the port has no disconnect to reject it, so the window
    // event has to. Otherwise the dApp waits forever on a window that is gone.
    test("a window that closes before its popup ever connected still rejects", async () => {
        const bg = loadBackground();
        const pending = bg.requestSite();
        await settle();

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

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

    // The `isSite &&` half of that skip, which is what keeps it from reaching
    // a tx or sign approval. The popup connects its port in show() before it
    // knows the approval's type, and the background sets portConnected without
    // looking at the type either, so a tx approval in the fallback window
    // carries the flag too. Without the conjunct the window event would skip
    // it, windowClosed would never be set, releaseApproval() would never settle
    // it, and the page would hang — the #271 regression this guard is written
    // around.
    test("a tx window closed with the port connected still rejects", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        bg.connectApproval(pending.id());
        await settle();
        bg.closeWindow(1);
        await settle();
        expect(pending.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });
    });
});
