Files
AutistMask/tests/stateUnusableRpc.test.js
T
sneak 58810fa2b7
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 3s
fix: open an approval window while another one has focus (closes #290)
The background centred each approval window on the last focused window,
which could be an earlier approval window still open. Headless Chrome
reports one as 1280x720, so the position came out where the browser refused
to create the window ("Bounds must be at least 50% within visible screen
space"), and the request failed with -32603 and no window. It now centres on
the last focused browser window.

Under load a test in the Chrome suite raised its prompt while the previous
test's window was still closing, and either hit that refusal or took the
closing window for its own. The runner now closes every approval window
between tests.

Model: opus-5-5
2026-10-05 04:40:35 +00:00

204 lines
7.0 KiB
JavaScript

// What a dApp is told when the wallet's stored profile cannot be read
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// The popup is not the only casualty of a bad blob. getActiveAddress()
// dereferences the stored wallet list on nearly every method, so against the
// build this file was added to, EVERY request from EVERY page came back as
// -32603 "AutistMask could not complete this request because of an internal
// error" — the code the wallet also answers when a signing attempt blows up,
// with nothing in it to tell the page or the user what is actually wrong or
// what to do about it.
//
// So what is pinned here is that the answer is SPECIFIC: its own code, and a
// message that says the saved data cannot be read, that nothing was signed or
// sent, and where to go to fix it.
//
// Same cold-worker shape as tests/coldWorkerChainId.test.js: the real state
// modules, over a storage stub, with no loadState() of the test's own — the
// handler has to reach storage by itself, as a worker revived by the page's
// own message does.
const CONNECTED_ORIGIN = "https://dapp.example";
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The generic answer, quoted rather than imported: this file's whole point is
// that the state-unusable path stopped using it.
const GENERIC_INTERNAL_ERROR_CODE = -32603;
// EIP-1474's assigned non-standard codes, verbatim. The spec sets aside
// -32000..-32099 for implementation-defined server errors but hands out
// meanings for the first seven, so those are exactly the codes this condition
// may NOT take: a page reading -32001 is entitled to read "Resource not
// found". -32002 is in this table AND in use here, for a pending approval.
const EIP_1474_ASSIGNED = {
"-32000": "Invalid input",
"-32001": "Resource not found",
"-32002": "Resource unavailable",
"-32003": "Transaction rejected",
"-32004": "Method not supported",
"-32005": "Limit exceeded",
"-32006": "JSON-RPC version not supported",
};
// The three blobs from the issue.
const CORRUPT_BLOBS = [
{
name: "wallets is a string",
blob: { hasWallet: true, wallets: ADDRESS },
},
{
name: "wallets is an array of garbage",
blob: { hasWallet: true, wallets: [null, 42, "wallet"] },
},
{
name: "future-schema blob (unknown fields, no version)",
blob: {
hasWallet: true,
wallets: [{ id: "wallet-1", accounts: [{ addr: ADDRESS }] }],
profileFormat: "am-2",
},
},
];
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
function loadColdWorker(stored) {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
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(),
}));
const store = { autistmask: structuredClone(stored) };
let messageListener = null;
const set = jest.fn(async (items) => {
store.autistmask = structuredClone(items.autistmask);
});
global.chrome = {
storage: {
local: {
get: jest.fn(async () => structuredClone(store)),
set,
},
},
runtime: {
getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (queryOptions, cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function rpc(method, params) {
let result = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: params || [] },
{ origin: CONNECTED_ORIGIN },
(r) => {
result = r;
},
);
await settle();
return result;
}
return { rpc, persisted: () => store.autistmask, storageSet: set };
}
// Every method a page can reach that has to consult the profile.
const METHODS = [
"eth_accounts",
"eth_requestAccounts",
"eth_chainId",
"personal_sign",
"eth_sendTransaction",
];
describe("a dApp call against a profile the wallet cannot read", () => {
for (const { name, blob } of CORRUPT_BLOBS) {
test(`${name}: a specific error, not the generic internal one`, async () => {
const bg = loadColdWorker(blob);
const answer = await bg.rpc("eth_accounts");
expect(answer.error).toBeDefined();
expect(answer.error.code).not.toBe(GENERIC_INTERNAL_ERROR_CODE);
// The message has to say what is wrong, that nothing was sent,
// and where to go. "Internal error" says none of the three.
expect(answer.error.message).toMatch(/saved data/i);
expect(answer.error.message).toMatch(/nothing was/i);
expect(answer.error.message).toMatch(/AutistMask/);
});
}
test("every method that consults the profile answers the same way", async () => {
const bg = loadColdWorker(CORRUPT_BLOBS[0].blob);
const codes = new Set();
for (const method of METHODS) {
const answer = await bg.rpc(method, ["0x00", ADDRESS]);
expect(answer.error).toBeDefined();
codes.add(answer.error.code);
}
// One code for the condition, whatever the method was.
expect(codes.size).toBe(1);
expect(codes.has(GENERIC_INTERNAL_ERROR_CODE)).toBe(false);
// And it is a code EIP-1474 has not already given a meaning to, so a
// page reading it by the spec's table is not told something false.
const code = [...codes][0];
expect(EIP_1474_ASSIGNED[String(code)]).toBeUndefined();
expect(code).toBeLessThanOrEqual(-32007);
expect(code).toBeGreaterThanOrEqual(-32099);
});
test("it does not write over the record it could not read", async () => {
const bg = loadColdWorker(CORRUPT_BLOBS[1].blob);
await bg.rpc("eth_accounts");
await bg.rpc("eth_chainId");
expect(bg.storageSet).not.toHaveBeenCalled();
expect(bg.persisted()).toEqual(CORRUPT_BLOBS[1].blob);
});
});