fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 30s
All checks were successful
check / check (push) Successful in 30s
Approve and window.close() left the popup on the next line, and the decision and the disconnect the close caused travelled independent channels with nothing ordering them. The disconnect handler settled a pending site approval as a rejection, so whichever landed first decided the outcome. Driven in a tab the teardown won every time: the user allowed the connection and the dApp was told they had refused. The decision now goes out on the approval port the popup already opens, which is the same port the close disconnects. One channel is ordered -- a message posted on a port is delivered before that port's own disconnect -- so the approval is settled before the teardown is even seen, and the disconnect then finds nothing pending to reject. Nothing waits, nothing is timed, and the popup closes exactly as immediately as before. windows.onRemoved no longer decides a site approval whose port is connected either. In the fallback-window shape that event races the decision on a channel of its own, which is the same defect one level over; the port disconnect says the same thing in a defined order, so it is left to say it. A window that closes before its popup ever connected has nothing else to speak for it and is still rejected there, so no dApp is left waiting on a window that is gone. Rejecting reports a rejection, and so does closing without deciding, in both shapes. AUTISTMASK_APPROVAL_RESPONSE is gone; the port name carries the approval id, so the popup no longer names one, and the sender check the message carried moved to the port. tests/backgroundApproval.test.js drives decide-then-disconnect with nothing awaited in between, in the toolbar-popup shape that production uses and in the fallback-window shape, and asserts every close-without-deciding path still rejects. tests/e2e/run.js drops the deferred-window.close() accommodation it carried for this bug, so the two site-prompt tests now drive the shipped decide-then-close in a real Chromium.
This commit is contained in:
@@ -1596,32 +1596,13 @@ async function reserveApprovalTab(env) {
|
||||
// one down with it.
|
||||
env.approvalTab = await env.ctx.newPage();
|
||||
|
||||
// The one accommodation this section makes to the shipped code, and the
|
||||
// reason for it.
|
||||
//
|
||||
// Both approval buttons call runtime.sendMessage() and then window.close()
|
||||
// on the next line. Closing this page disconnects the approval port, and
|
||||
// the disconnect handler in src/background/index.js settles a pending
|
||||
// site approval as a rejection. In a tab those two race and the teardown
|
||||
// wins: the approve message is never acted on, and the page is told the
|
||||
// user rejected. Measured — with the close left in place the approval
|
||||
// resolves as a rejection every time; with it deferred it resolves as an
|
||||
// approval every time.
|
||||
//
|
||||
// It is deferred, not removed: the harness closes the page itself once
|
||||
// the outcome has been observed, which is what window.close() would have
|
||||
// done, only after the message it was racing has been processed.
|
||||
//
|
||||
// This affects the site-connection prompt only. The sign and transaction
|
||||
// prompts run in windows the extension opens itself, with window.close()
|
||||
// untouched, and their disconnect handler deliberately keeps a tx or sign
|
||||
// approval pending rather than rejecting it — so there is no race there
|
||||
// to accommodate. Whether the same ordering holds in a real toolbar popup
|
||||
// is not observable from a headless harness and is reported rather than
|
||||
// assumed either way.
|
||||
await env.approvalTab.addInitScript(() => {
|
||||
window.close = function () {};
|
||||
});
|
||||
// This tab runs the shipped popup with nothing patched. The site
|
||||
// approval buttons decide and then close on the next line, and the two
|
||||
// site-approval tests below are therefore the real-browser
|
||||
// approve-then-immediate-close and reject-then-immediate-close cases: the
|
||||
// decision rides the approval port, which also carries the disconnect the
|
||||
// close causes, so it is delivered ahead of it and the outcome does not
|
||||
// depend on the teardown timing (#275).
|
||||
await env.approvalTab.goto("about:blank");
|
||||
await sleep(APPROVAL_TAB_SETTLE_MS);
|
||||
return env.approvalTab;
|
||||
@@ -1670,6 +1651,28 @@ async function closeApprovalPages(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// Click a button whose own handler closes the window it lives in — every
|
||||
// Reject, and Allow on the site prompt.
|
||||
//
|
||||
// page.click() dispatches the click and then waits for the renderer to
|
||||
// acknowledge it, and a page torn down by the handler never gets to. The
|
||||
// dispatch is what the test needs and the log shows it happening ("performing
|
||||
// click action") immediately before the failure; the page going away is the
|
||||
// button working, not the click failing. Observed on #btn-reject-sign and
|
||||
// #btn-reject-tx, whose windows have always closed themselves.
|
||||
//
|
||||
// This swallows nothing that matters: a click that did not land leaves the
|
||||
// dApp promise unsettled and the assertion after the call still fails. A
|
||||
// button that is missing or unclickable raises a different error, which is
|
||||
// rethrown.
|
||||
async function clickAndClose(page, selector) {
|
||||
try {
|
||||
await page.click(selector);
|
||||
} catch (e) {
|
||||
if (!String((e && e.message) || e).includes("has been closed")) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Record every message the approval window sends to the background worker.
|
||||
//
|
||||
// This is the direct observation the password check needs. It is installed
|
||||
@@ -1890,7 +1893,7 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
|
||||
// origin in deniedSites and every later test in this section is
|
||||
// auto-rejected with no prompt at all, which would look like a pass.
|
||||
await popup.uncheck("#approve-remember");
|
||||
await popup.click("#btn-reject");
|
||||
await clickAndClose(popup, "#btn-reject");
|
||||
|
||||
await assertUserRejection(
|
||||
env.dapp,
|
||||
@@ -1921,7 +1924,7 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
|
||||
// does not, and the sign and transaction tests below all require the
|
||||
// origin to still be authorized.
|
||||
await popup.check("#approve-remember");
|
||||
await popup.click("#btn-approve");
|
||||
await clickAndClose(popup, "#btn-approve");
|
||||
|
||||
outcome = await settleRequest(env.dapp, "accounts");
|
||||
} finally {
|
||||
@@ -2029,7 +2032,7 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
|
||||
]);
|
||||
const popup = await waitForApprovalWindow(env.ctx);
|
||||
await visible(popup, "#view-approve-sign");
|
||||
await popup.click("#btn-reject-sign");
|
||||
await clickAndClose(popup, "#btn-reject-sign");
|
||||
|
||||
await assertUserRejection(
|
||||
env.dapp,
|
||||
@@ -2132,7 +2135,7 @@ test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", asy
|
||||
]);
|
||||
const popup = await waitForApprovalWindow(env.ctx);
|
||||
await visible(popup, "#view-approve-sign");
|
||||
await popup.click("#btn-reject-sign");
|
||||
await clickAndClose(popup, "#btn-reject-sign");
|
||||
|
||||
await assertUserRejection(
|
||||
env.dapp,
|
||||
@@ -2290,7 +2293,7 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
|
||||
]);
|
||||
const popup = await waitForApprovalWindow(env.ctx);
|
||||
await visible(popup, "#view-approve-tx");
|
||||
await popup.click("#btn-reject-tx");
|
||||
await clickAndClose(popup, "#btn-reject-tx");
|
||||
|
||||
await assertUserRejection(
|
||||
env.dapp,
|
||||
|
||||
Reference in New Issue
Block a user