Compare commits
1 Commits
277ec8c8f8
...
4343d8fc77
| Author | SHA1 | Date | |
|---|---|---|---|
| 4343d8fc77 |
16
TODO.md
16
TODO.md
@@ -64,16 +64,12 @@ 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 and matches every specifier syntax
|
walks the background's require graph, so a re-export cannot put the singleton
|
||||||
esbuild resolves — quoted, backtick, dynamic `import()` and `from` clause — so
|
back in the bundle, and reading an unloaded singleton now throws
|
||||||
neither a re-export nor an unusual specifier can put the singleton back in the
|
`StateNotLoadedError` instead of serving defaults. The `chrome.storage.local`
|
||||||
bundle; the rule's own coverage is pinned by
|
stubs in eight test files aliased instead of structured-cloning, which could
|
||||||
`tests/backgroundStateLintRule.test.js`. Reading an unloaded singleton now
|
let an assertion pass on a build that never wrote anything; they all go
|
||||||
throws `StateNotLoadedError` instead of serving defaults. The
|
through `tests/support/storageStub.js` now.
|
||||||
`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
|
||||||
|
|||||||
@@ -17,23 +17,10 @@
|
|||||||
// 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 import specifiers textually.
|
// The walk reads sources from disk and matches `require("...")` textually.
|
||||||
// That over-approximates — a specifier inside a comment or a string counts —
|
// That over-approximates — a require inside a comment or a string counts — and
|
||||||
// and over-approximating is the safe direction for a prohibition: the failure
|
// over-approximating is the safe direction for a prohibition: the failure mode
|
||||||
// mode is a spurious error naming an exact file and line, not a silent hole.
|
// 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");
|
||||||
@@ -41,11 +28,7 @@ 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");
|
||||||
|
|
||||||
// Both alternatives capture the specifier: call form first
|
const REQUIRE_RE = /\brequire\(\s*["']([^"']+)["']\s*\)/g;
|
||||||
// (`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) {
|
||||||
@@ -74,8 +57,8 @@ function requiresOf(file) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const match of source.matchAll(SPECIFIER_RE)) {
|
for (const match of source.matchAll(REQUIRE_RE)) {
|
||||||
const resolved = resolveRelative(file, match[1] ?? match[2]);
|
const resolved = resolveRelative(file, match[1]);
|
||||||
if (resolved) out.push(resolved);
|
if (resolved) out.push(resolved);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -59,11 +59,6 @@ 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));
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
// 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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,15 +13,13 @@
|
|||||||
// 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 the shared one from
|
// That makes the storage stub load-bearing, so it is a real store that
|
||||||
// tests/support/storageStub.js, a real store that structured-clones on both
|
// structured-clones on both `set` and `get`. A stub whose `get` hands back
|
||||||
// `set` and `get`. A stub whose `get` hands back the same object its `set`
|
// the same object its `set` was given aliases the caller's own array: the
|
||||||
// was given aliases the caller's own array: the test then reads its own
|
// test then reads its own in-memory mutation and calls it persistence, and
|
||||||
// in-memory mutation and calls it persistence, and passes against a build
|
// passes against a build that persists nothing (see issue #324). The
|
||||||
// that persists nothing
|
// aliasing is closed off explicitly by the first test below rather than
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
|
// left as an assumption about `structuredClone`.
|
||||||
// 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
|
||||||
@@ -36,7 +34,6 @@ 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";
|
||||||
|
|
||||||
@@ -97,6 +94,35 @@ 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) {
|
||||||
@@ -118,10 +144,10 @@ function load() {
|
|||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
mockSettingsShow.mockClear();
|
mockSettingsShow.mockClear();
|
||||||
|
|
||||||
const storage = makeStorageStub();
|
const storage = makeStorage();
|
||||||
const sent = [];
|
const sent = [];
|
||||||
globalThis.chrome = {
|
globalThis.chrome = {
|
||||||
storage: { local: storage.local },
|
storage: { local: storage },
|
||||||
runtime: { sendMessage: (msg) => sent.push(msg) },
|
runtime: { sendMessage: (msg) => sent.push(msg) },
|
||||||
};
|
};
|
||||||
globalThis.document = makeDocument();
|
globalThis.document = makeDocument();
|
||||||
@@ -179,7 +205,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 = makeStorageStub();
|
const storage = makeStorage();
|
||||||
const written = { wallets: [{ name: "Wallet 1" }] };
|
const written = { wallets: [{ name: "Wallet 1" }] };
|
||||||
|
|
||||||
await storage.set({ autistmask: written });
|
await storage.set({ autistmask: written });
|
||||||
@@ -367,8 +393,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.read())).not.toContain("secret-two");
|
expect(JSON.stringify(storage._raw())).not.toContain("secret-two");
|
||||||
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
|
expect(JSON.stringify(storage._raw())).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 () => {
|
||||||
@@ -436,7 +462,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.read())).not.toContain("secret-one");
|
expect(JSON.stringify(storage._raw())).not.toContain("secret-one");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,32 @@
|
|||||||
//
|
//
|
||||||
// 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 shared stub structured-clones on
|
// pages share one chrome.storage.local. The storage stub structured-clones
|
||||||
// both get and set — a stub that hands back the object it was given aliases
|
// on both get and set — a stub that hands back the object it was given
|
||||||
// the caller's own mutation and would make this entire defect class invisible
|
// aliases the caller's own mutation and would make this entire defect class
|
||||||
// (see https://git.eeqj.de/sneak/AutistMask/issues/324).
|
// invisible (see https://git.eeqj.de/sneak/AutistMask/issues/324).
|
||||||
|
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -23,7 +43,7 @@ const { makeStorageStub } = require("./support/storageStub");
|
|||||||
// 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.local } };
|
globalThis.chrome = { storage: { local: storage } };
|
||||||
return {
|
return {
|
||||||
state: require("../src/shared/state"),
|
state: require("../src/shared/state"),
|
||||||
helpers: require("../src/popup/views/helpers"),
|
helpers: require("../src/popup/views/helpers"),
|
||||||
@@ -98,7 +118,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 = makeStorageStub();
|
const storage = makeStorage();
|
||||||
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 —
|
||||||
@@ -151,7 +171,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 = makeStorageStub();
|
const storage = makeStorage();
|
||||||
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
|
||||||
@@ -203,7 +223,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 = makeStorageStub();
|
const storage = makeStorage();
|
||||||
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
|
||||||
@@ -243,7 +263,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 = makeStorageStub();
|
const storage = makeStorage();
|
||||||
await storage.set({ autistmask: { wallets: [W1, W2] } });
|
await storage.set({ autistmask: { wallets: [W1, W2] } });
|
||||||
|
|
||||||
const background = loadPage(storage);
|
const background = loadPage(storage);
|
||||||
@@ -304,7 +324,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 = makeStorageStub();
|
const storage = makeStorage();
|
||||||
await storage.set({
|
await storage.set({
|
||||||
autistmask: {
|
autistmask: {
|
||||||
wallets: [W1],
|
wallets: [W1],
|
||||||
@@ -342,7 +362,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 = makeStorageStub();
|
const storage = makeStorage();
|
||||||
await storage.set({
|
await storage.set({
|
||||||
autistmask: {
|
autistmask: {
|
||||||
wallets: [W1],
|
wallets: [W1],
|
||||||
@@ -393,7 +413,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 = makeStorageStub();
|
const storage = makeStorage();
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user