Compare commits

..

1 Commits

Author SHA1 Message Date
277ec8c8f8 harden: make the background physically unable to read the shared state singleton (closes #324)
All checks were successful
check / check (push) Successful in 50s
e2e / e2e-chrome (push) Successful in 1m25s
e2e / e2e-firefox (push) Successful in 38s
Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.

So the reachability goes rather than a sixth call site.

The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.

- Every handler takes one snapshot and answers from it, including the address
  it names: activeAddressOf(s) replaced a second, later storage read that
  could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
  chainSwitch.js, which keeps the singleton path for the popup) inside
  updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
  around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
  balances that came back by address, so it never publishes an object other
  in-flight work holds, and a wallet added or deleted during the round trip
  survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
  snapshot. They used to come from different moments, so a chain switch
  committed in between moved the endpoint under an artifact already verified
  against the old chain.

getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.

The prohibition is enforced mechanically, not by review: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.

The rule's matcher covers every specifier syntax esbuild resolves statically —
quoted require, backtick require, dynamic import(), and a static import/export
`from` clause — because a narrower match is not a matter of tidiness but a sixth
site the build cannot see: each of those shapes was measured to put state.js in
dist/chrome/src/background/index.js while the lint stayed clean.
tests/backgroundStateLintRule.test.js pins all of them, plus the two-hop
re-export, against a real fixture tree. A computed specifier
(require("../shared/" + "state")) is deliberately not matched: esbuild cannot
resolve it either, so it never reaches the bundle.

Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.

Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.

closes #320
2026-08-23 14:08:14 +00:00
6 changed files with 259 additions and 88 deletions

16
TODO.md
View File

@@ -64,12 +64,16 @@ but the review is broader than any of them.
REQUIRES the network id, which is what closes REQUIRES the network id, which is what closes
[#320](https://git.eeqj.de/sneak/AutistMask/issues/320) at the shape rather [#320](https://git.eeqj.de/sneak/AutistMask/issues/320) at the shape rather
than at the call site. The prohibition is enforced by an ESLint rule that than at the call site. The prohibition is enforced by an ESLint rule that
walks the background's require graph, so a re-export cannot put the singleton walks the background's require graph and matches every specifier syntax
back in the bundle, and reading an unloaded singleton now throws esbuild resolves — quoted, backtick, dynamic `import()` and `from` clause — so
`StateNotLoadedError` instead of serving defaults. The `chrome.storage.local` neither a re-export nor an unusual specifier can put the singleton back in the
stubs in eight test files aliased instead of structured-cloning, which could bundle; the rule's own coverage is pinned by
let an assertion pass on a build that never wrote anything; they all go `tests/backgroundStateLintRule.test.js`. Reading an unloaded singleton now
through `tests/support/storageStub.js` now. throws `StateNotLoadedError` instead of serving defaults. The
`chrome.storage.local` stubs in eight test files aliased instead of
structured-cloning, which could let an assertion pass on a build that never
wrote anything; every test that drives real persistence now goes through
`tests/support/storageStub.js`.
- 2026-08-23: A failed release build no longer leaves a loadable debug bundle in - 2026-08-23: A failed release build no longer leaves a loadable debug bundle in
`dist/` ([#333](https://git.eeqj.de/sneak/AutistMask/issues/333)). With `dist/` ([#333](https://git.eeqj.de/sneak/AutistMask/issues/333)). With
`AUTISTMASK_DEBUG=1` exported, `make build` compiled a debug bundle and failed `AUTISTMASK_DEBUG=1` exported, `make build` compiled a debug bundle and failed

View File

@@ -17,10 +17,23 @@
// the root of a walk over the CommonJS require graph, and the error names the // the root of a walk over the CommonJS require graph, and the error names the
// whole chain that brought the singleton in. // whole chain that brought the singleton in.
// //
// The walk reads sources from disk and matches `require("...")` textually. // The walk reads sources from disk and matches import specifiers textually.
// That over-approximates — a require inside a comment or a string counts — and // That over-approximates — a specifier inside a comment or a string counts —
// over-approximating is the safe direction for a prohibition: the failure mode // and over-approximating is the safe direction for a prohibition: the failure
// is a spurious error naming an exact file and line, not a silent hole. // mode is a spurious error naming an exact file and line, not a silent hole.
//
// It has to match EVERY specifier syntax esbuild resolves statically, because
// the hole a narrower match leaves is not "the rule is less tidy", it is a
// sixth site the build cannot see. Matching only `require("x")` and `require('x')`
// let four shapes through, each of which was confirmed to put the singleton in
// the shipped worker bundle: a backtick `require(`x`)`, a dynamic `import("x")`,
// a static `import ... from "x"` / `export ... from "x"`, and any of those one
// hop away in a shared module the background already pulls in.
//
// Known and deliberate gap: a computed specifier, `require("../shared/" +
// "state")`. It is not matched here, and it is not a hole — esbuild cannot
// resolve it statically either, so it never reaches the bundle. Contorting the
// rule to chase it would buy nothing.
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -28,7 +41,11 @@ const path = require("path");
// The module this rule exists to keep out, relative to the repo root. // The module this rule exists to keep out, relative to the repo root.
const FORBIDDEN = path.join("src", "shared", "state.js"); const FORBIDDEN = path.join("src", "shared", "state.js");
const REQUIRE_RE = /\brequire\(\s*["']([^"']+)["']\s*\)/g; // Both alternatives capture the specifier: call form first
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
// side-effect `import "x"`).
const SPECIFIER_RE =
/\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)|\b(?:from|import)\s+["'`]([^"'`]+)["'`]/g;
// Resolve a relative require to a file path, trying the extensions node would. // Resolve a relative require to a file path, trying the extensions node would.
function resolveRelative(fromFile, spec) { function resolveRelative(fromFile, spec) {
@@ -57,8 +74,8 @@ function requiresOf(file) {
return []; return [];
} }
const out = []; const out = [];
for (const match of source.matchAll(REQUIRE_RE)) { for (const match of source.matchAll(SPECIFIER_RE)) {
const resolved = resolveRelative(file, match[1]); const resolved = resolveRelative(file, match[1] ?? match[2]);
if (resolved) out.push(resolved); if (resolved) out.push(resolved);
} }
return out; return out;

View File

@@ -59,6 +59,11 @@ async function updateStateOnce(mutate) {
// write is lost, and keeping it to one storage round trip is what makes a // write is lost, and keeping it to one storage round trip is what makes a
// whole-record write safe here. // whole-record write safe here.
// //
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the queue is strictly serial, so the inner turn waits on
// the outer one, which is waiting on the inner one. That deadlocks the whole
// background, not just the caller. Mutate the record you were handed.
//
// Resolves with the record that was written. // Resolves with the record that was written.
function updateState(mutate) { function updateState(mutate) {
const turn = updateQueue.then(() => updateStateOnce(mutate)); const turn = updateQueue.then(() => updateStateOnce(mutate));

View File

@@ -0,0 +1,191 @@
// The lint rule that keeps src/shared/state.js out of the background bundle
// (script/lib/eslint/noStateSingletonInBackground.js).
//
// Five defects, one of which destroyed a wallet, came from background code
// reaching that singleton, and each point fix created the next site
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The prohibition is
// therefore mechanical rather than a review item — which means the rule's
// coverage is itself load-bearing, and a hole in it is indistinguishable from
// having no rule at all.
//
// The hole this file exists to pin shut is SPECIFIER SYNTAX. The rule walks the
// require graph textually, and a first version matched only `require("x")` and
// `require('x')`. Every shape below was measured against a real `make build`:
// each one puts state.js in dist/chrome/src/background/index.js, and each one
// was invisible to the narrower match. So each is a case here, and a regression
// in the matcher fails the suite instead of shipping a sixth site.
//
// NOT covered, deliberately: a computed specifier such as
// `require("../shared/" + "state")`. esbuild cannot resolve that statically
// either, so it never reaches the bundle — there is nothing to block.
const fs = require("fs");
const os = require("os");
const path = require("path");
const { Linter } = require("eslint");
const plugin = require("../script/lib/eslint/noStateSingletonInBackground");
const RULE = "background/no-state-singleton-in-background";
// The three files a fixture tree always has. `src/background/index.js` is
// supplied per case; the other two stand in for the real modules.
const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n";
const SHARED_HOP =
"// A shared module the background legitimately imports.\n" +
"module.exports = { applyChainSwitchFields() {} };\n";
let roots = [];
function fixture(files) {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")),
);
roots.push(root);
const tree = {
"src/shared/state.js": SHARED_STATE,
"src/shared/chainSwitchFields.js": SHARED_HOP,
...files,
};
for (const [rel, source] of Object.entries(tree)) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, source);
}
return root;
}
// Run the rule exactly as eslint.config.js runs it, over a real tree: the walk
// reads its sources from disk, so a virtual RuleTester would not exercise it.
// `sourceType` is the fixture's own, not the rule's business: the walk is
// textual and never parses the files it follows. The two ESM cases below pass
// "module" only so espree can parse the fixture at all — in this repo those
// shapes are also a parse error under the commonjs config, but the rule must
// not be left depending on that.
function lintBackground(root, { sourceType = "commonjs" } = {}) {
const file = path.join(root, "src/background/index.js");
const linter = new Linter({ cwd: root });
return linter.verify(
fs.readFileSync(file, "utf8"),
{
plugins: { background: plugin },
languageOptions: { ecmaVersion: 2024, sourceType },
rules: { [RULE]: "error" },
},
file,
);
}
function chainOf(messages) {
expect(messages).toHaveLength(1);
expect(messages[0].ruleId).toBe(RULE);
// "...singleton: <chain>. The MV3 worker..." — the chain is what the
// message exists to hand the reader, so assert on it rather than on the
// fact that something was reported.
return messages[0].message.split("singleton: ")[1].split(". The MV3")[0];
}
afterEach(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
describe("every specifier syntax esbuild resolves is blocked", () => {
test("a quoted require", () => {
const root = fixture({
"src/background/index.js":
'const { state } = require("../shared/state");\n' +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a backtick require", () => {
const root = fixture({
"src/background/index.js":
"const { state } = require(`../shared/state`);\n" +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a dynamic import inside an async function", () => {
const root = fixture({
"src/background/index.js":
"async function readState() {\n" +
' const m = await import("../shared/state");\n' +
" return m.state;\n" +
"}\n" +
"module.exports = { readState };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a static import from-clause", () => {
const root = fixture({
"src/background/index.js":
'import { state } from "../shared/state";\n' +
"export { state };\n",
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a bare side-effect import", () => {
const root = fixture({
"src/background/index.js": 'import "../shared/state";\n',
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
});
describe("reachability, not just the direct specifier", () => {
// The shape a no-restricted-imports could never see: no background file
// names state.js, and the singleton is in the bundle anyway. In a backtick
// require, so this fails on the specifier widening as well as on the walk.
test("a two-hop re-export through a shared module", () => {
const root = fixture({
"src/background/index.js":
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { applyChainSwitchFields };\n",
"src/shared/chainSwitchFields.js":
SHARED_HOP +
"module.exports.state = require(`./state`).state;\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/chainSwitchFields.js" +
" -> src/shared/state.js",
);
});
});
describe("what the rule must not report", () => {
test("a background file that reaches only its own state layer", () => {
const root = fixture({
"src/background/index.js":
'const { getState } = require("./state");\n' +
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { getState, applyChainSwitchFields };\n",
"src/background/state.js":
"async function getState() {}\nmodule.exports = { getState };\n",
});
expect(lintBackground(root)).toEqual([]);
});
// The tree as it actually stands. This is the assertion that would catch a
// widened matcher that resolves something it should not: it runs the rule
// over the real background entrypoint, from the real repo root.
test("the repository's own background entrypoint", () => {
const root = path.resolve(__dirname, "..");
expect(lintBackground(root)).toEqual([]);
});
});

View File

@@ -13,13 +13,15 @@
// never persisting it looks identical from `state`, and a build that never // never persisting it looks identical from `state`, and a build that never
// wrote at all would pass a check that only reads `state` back. // wrote at all would pass a check that only reads `state` back.
// //
// That makes the storage stub load-bearing, so it is a real store that // That makes the storage stub load-bearing, so it is the shared one from
// structured-clones on both `set` and `get`. A stub whose `get` hands back // tests/support/storageStub.js, a real store that structured-clones on both
// the same object its `set` was given aliases the caller's own array: the // `set` and `get`. A stub whose `get` hands back the same object its `set`
// test then reads its own in-memory mutation and calls it persistence, and // was given aliases the caller's own array: the test then reads its own
// passes against a build that persists nothing (see issue #324). The // in-memory mutation and calls it persistence, and passes against a build
// aliasing is closed off explicitly by the first test below rather than // that persists nothing
// left as an assumption about `structuredClone`. // (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
// off explicitly by the first test below rather than left as an assumption
// about `structuredClone`.
// //
// The view is driven against a minimal DOM stub, in the same shape as // The view is driven against a minimal DOM stub, in the same shape as
// tests/exportPrivkey.test.js: the module reads and writes named nodes and // tests/exportPrivkey.test.js: the module reads and writes named nodes and
@@ -34,6 +36,7 @@ jest.mock("../src/shared/vault", () => ({
})); }));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { makeStorageStub } = require("./support/storageStub");
const VIEW = "delete-wallet-lost-password"; const VIEW = "delete-wallet-lost-password";
@@ -94,35 +97,6 @@ function makeDocument() {
}; };
} }
// --------------------------------------------------------- storage stub
// A store that behaves the way `chrome.storage.local` does: what goes in is
// serialized, so the caller keeps no handle on what came to rest there, and
// what comes out is a fresh object the caller may mutate freely.
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
// Test-only: what the extension would find on a cold start.
_raw: () => structuredClone(store),
};
}
// ------------------------------------------------------------ harness // ------------------------------------------------------------ harness
function wallet(name, secret, addresses) { function wallet(name, secret, addresses) {
@@ -144,10 +118,10 @@ function load() {
jest.resetModules(); jest.resetModules();
mockSettingsShow.mockClear(); mockSettingsShow.mockClear();
const storage = makeStorage(); const storage = makeStorageStub();
const sent = []; const sent = [];
globalThis.chrome = { globalThis.chrome = {
storage: { local: storage }, storage: { local: storage.local },
runtime: { sendMessage: (msg) => sent.push(msg) }, runtime: { sendMessage: (msg) => sent.push(msg) },
}; };
globalThis.document = makeDocument(); globalThis.document = makeDocument();
@@ -205,7 +179,7 @@ async function openLostPassword(deleteWallet, walletIdx) {
// every other test in this file against a build that never writes. // every other test in this file against a build that never writes.
describe("the storage stub", () => { describe("the storage stub", () => {
test("does not hand back the object it was given", async () => { test("does not hand back the object it was given", async () => {
const storage = makeStorage(); const storage = makeStorageStub();
const written = { wallets: [{ name: "Wallet 1" }] }; const written = { wallets: [{ name: "Wallet 1" }] };
await storage.set({ autistmask: written }); await storage.set({ autistmask: written });
@@ -393,8 +367,8 @@ describe("deleting without the password", () => {
// The deleted wallet's secret is gone from storage entirely, not // The deleted wallet's secret is gone from storage entirely, not
// merely unreferenced by the wallet list. // merely unreferenced by the wallet list.
expect(JSON.stringify(storage._raw())).not.toContain("secret-two"); expect(JSON.stringify(storage.read())).not.toContain("secret-two");
expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2"); expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
}); });
test("only the deleted wallet's site permissions are dropped", async () => { test("only the deleted wallet's site permissions are dropped", async () => {
@@ -462,7 +436,7 @@ describe("deleting without the password", () => {
expect(saved.activeAddress).toBeNull(); expect(saved.activeAddress).toBeNull();
expect(saved.allowedSites).toEqual({}); expect(saved.allowedSites).toEqual({});
expect(state.currentView).toBe("welcome"); expect(state.currentView).toBe("welcome");
expect(JSON.stringify(storage._raw())).not.toContain("secret-one"); expect(JSON.stringify(storage.read())).not.toContain("secret-one");
}); });
}); });

View File

@@ -10,32 +10,12 @@
// //
// Both cases below drive the real state.js module through two independent // Both cases below drive the real state.js module through two independent
// module registries sharing one storage backend, the way two real extension // module registries sharing one storage backend, the way two real extension
// pages share one chrome.storage.local. The storage stub structured-clones // pages share one chrome.storage.local. The shared stub structured-clones on
// on both get and set — a stub that hands back the object it was given // both get and set — a stub that hands back the object it was given aliases
// aliases the caller's own mutation and would make this entire defect class // the caller's own mutation and would make this entire defect class invisible
// invisible (see https://git.eeqj.de/sneak/AutistMask/issues/324). // (see https://git.eeqj.de/sneak/AutistMask/issues/324).
function makeStorage() { const { makeStorageStub } = require("./support/storageStub");
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
};
}
// One extension page: a fresh module registry over the shared storage. // One extension page: a fresh module registry over the shared storage.
// state.js resolves the storage API at require time, so the stub has to be // state.js resolves the storage API at require time, so the stub has to be
@@ -43,7 +23,7 @@ function makeStorage() {
// singleton, so each page needs its own registry to hold its own copy. // singleton, so each page needs its own registry to hold its own copy.
function loadPage(storage) { function loadPage(storage) {
jest.resetModules(); jest.resetModules();
globalThis.chrome = { storage: { local: storage } }; globalThis.chrome = { storage: { local: storage.local } };
return { return {
state: require("../src/shared/state"), state: require("../src/shared/state"),
helpers: require("../src/popup/views/helpers"), helpers: require("../src/popup/views/helpers"),
@@ -118,7 +98,7 @@ describe("a save from a page that never saw a wallet another page added", () =>
// a save from a second page loaded before that wallet existed. Both // a save from a second page loaded before that wallet existed. Both
// wallets must survive. // wallets must survive.
test("both wallets are in storage afterwards", async () => { test("both wallets are in storage afterwards", async () => {
const storage = makeStorage(); const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } }); await storage.set({ autistmask: { wallets: [W1] } });
// Loaded while storage held only Wallet 1, and never reloads — // Loaded while storage held only Wallet 1, and never reloads —
@@ -171,7 +151,7 @@ describe("the approval-window reproduction", () => {
test("the wallet added in the popup survives confirming the approval", async () => { test("the wallet added in the popup survives confirming the approval", async () => {
globalThis.document = makeDocument(); globalThis.document = makeDocument();
const storage = makeStorage(); const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } }); await storage.set({ autistmask: { wallets: [W1] } });
// The background opens the approval window on the approve-tx // The background opens the approval window on the approve-tx
@@ -223,7 +203,7 @@ describe("the approval-window reproduction", () => {
// membership" collided as the same field. // membership" collided as the same field.
describe("background refresh racing a wallet added on another page", () => { describe("background refresh racing a wallet added on another page", () => {
test("the wallet added elsewhere survives background's stale balance save", async () => { test("the wallet added elsewhere survives background's stale balance save", async () => {
const storage = makeStorage(); const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } }); await storage.set({ autistmask: { wallets: [W1] } });
// "background": loads first, and its save is the one that lands // "background": loads first, and its save is the one that lands
@@ -263,7 +243,7 @@ describe("background refresh racing a wallet added on another page", () => {
describe("background refresh racing a wallet deleted on another page", () => { describe("background refresh racing a wallet deleted on another page", () => {
test("the wallet deleted elsewhere stays deleted after background's stale balance save", async () => { test("the wallet deleted elsewhere stays deleted after background's stale balance save", async () => {
const storage = makeStorage(); const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1, W2] } }); await storage.set({ autistmask: { wallets: [W1, W2] } });
const background = loadPage(storage); const background = loadPage(storage);
@@ -324,7 +304,7 @@ function revokeSite(pageState, hostname) {
describe("a dApp approval racing a stale Settings page's later save", () => { describe("a dApp approval racing a stale Settings page's later save", () => {
test("the fresh approval survives Settings revoking an unrelated site", async () => { test("the fresh approval survives Settings revoking an unrelated site", async () => {
const storage = makeStorage(); const storage = makeStorageStub();
await storage.set({ await storage.set({
autistmask: { autistmask: {
wallets: [W1], wallets: [W1],
@@ -362,7 +342,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
describe("a revoked site permission against a stale page's later save", () => { describe("a revoked site permission against a stale page's later save", () => {
test("the revocation holds even when the stale page approves something else", async () => { test("the revocation holds even when the stale page approves something else", async () => {
const storage = makeStorage(); const storage = makeStorageStub();
await storage.set({ await storage.set({
autistmask: { autistmask: {
wallets: [W1], wallets: [W1],
@@ -413,7 +393,7 @@ function legacyWallet(name, secret) {
describe("two wallets independently created with a colliding identity", () => { describe("two wallets independently created with a colliding identity", () => {
test("both survive, encryptedSecret included, instead of one silently replacing the other", async () => { test("both survive, encryptedSecret included, instead of one silently replacing the other", async () => {
const storage = makeStorage(); const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } }); await storage.set({ autistmask: { wallets: [W1] } });
// Both pages load before either has created their malformed wallet, // Both pages load before either has created their malformed wallet,