Files
AutistMask/tests/stateRecovery.test.js
T
sneak 057d49d5b2
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 3s
fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
A popup already open when the stored profile became unreadable stayed on the
last good profile until reopened. Every save already runs the check loadState()
runs at open; a save refused by it now stops the ten-second refresh, runs the
leave cleanup of the current screen, and raises the recovery screen. From then
on showView() shows nothing else in that popup, so a transaction wait or a later
save cannot take the user off it or clear an export or a typed confirmation.
That is held in memory, never as the saved current view, so a popup opened
after the record is erased elsewhere opens normally. Any other failed save
keeps the "NOT SAVED" banner. The popup test harness now honours
clearInterval().

Model: opus-5-5
2026-10-04 21:08:37 +00:00

537 lines
22 KiB
JavaScript

// A stored profile the popup cannot read must produce a SCREEN, not a blank
// popup (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// The three corrupt blobs below are the ones the pre-1.0 audit wrote into
// storage. Against the build this file was added to, each one rendered nothing
// at all — no view, no message, no control — because init() dereferenced
// `state.wallets[0].addresses` on a record nothing had validated and threw
// before the first showView().
//
// So the assertions here are deliberately made through the REAL popup entry
// point rather than against the recovery view module directly. A recovery
// screen that renders perfectly when something calls it, and that nothing
// calls, is exactly the defect: what has to be true is that BOOTING the popup
// on a bad blob lands on it.
//
// The boot harness and its DOM stub — built FROM src/popup/index.html, so
// "which views are visible" is answered against the real element set — live in
// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
// the same boot.
//
// The fourth case is the upgrade one, and it is the case that must NOT reach
// the recovery screen: every install in the field has a valid profile with no
// version field, and showing those users a wipe prompt would be a worse defect
// than the one being fixed. It is migrated in place and keeps working.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// ------------------------------------------------------------- fixtures
// The three blobs from the issue, each with the error it produced.
const CORRUPT_BLOBS = [
{
name: "wallets is a string",
blob: { hasWallet: true, wallets: ADDRESS, activeAddress: ADDRESS },
},
{
name: "wallets is an array of garbage",
blob: {
hasWallet: true,
wallets: [null, 42, "wallet"],
activeAddress: ADDRESS,
},
},
{
name: "future-schema blob (unknown fields, no version)",
blob: {
hasWallet: true,
// A later schema that renamed the field and moved the key
// material, written by a build this one knows nothing about, and
// stamped with no version because this build never wrote one.
wallets: [
{
id: "wallet-1",
label: "Wallet 1",
accounts: [{ addr: ADDRESS, wei: "0x0" }],
keyring: { kind: "hd", vault: "…" },
},
],
profileFormat: "am-2",
activeAccount: ADDRESS,
},
},
];
afterEach(cleanupPopup);
// --------------------------------------------------------------- tests
describe("a stored profile the popup cannot read", () => {
for (const { name, blob } of CORRUPT_BLOBS) {
test(`${name}: the recovery screen, not a blank popup`, async () => {
const env = await bootPopup(blob);
// Asserted together, and in the audit's own shape: a failure here
// prints both what was on screen and what the console said, which
// is the pair that identifies this defect.
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["state-recovery"], errors: [] });
// The screen has to NAME the problem. A blank recovery screen is
// the same dead end with a border around it.
expect(env.text("state-recovery-problem").length).toBeGreaterThan(
10,
);
});
}
test("the Settings gear is hidden, since every screen behind it reads the profile", async () => {
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
expect(env.hidden("btn-settings")).toBe(true);
});
test("it does not write over the record it could not read", async () => {
// The blob is evidence, and possibly the only copy of key material in
// a shape a later build could recover. A boot that normalized it back
// into storage would destroy exactly that.
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[2].blob);
expect(env.storage.set).not.toHaveBeenCalled();
});
});
describe("the export on the recovery screen", () => {
test("hands back the raw stored record verbatim", async () => {
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
await env.click("btn-state-recovery-export");
// Shown in the page, which always works, whatever the browser does
// with a download from an extension popup.
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(JSON.parse(env.value("state-recovery-blob"))).toEqual(
CORRUPT_BLOBS[2].blob,
);
});
});
describe("the destructive reset on the recovery screen", () => {
test("erases nothing without the typed confirmation", async () => {
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
env.node("state-recovery-reset-input").value = "yes";
await env.click("btn-state-recovery-reset");
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
expect(env.text("state-recovery-flash").length).toBeGreaterThan(10);
expect(env.reloaded()).toBe(0);
});
test("erases the stored profile once the phrase is typed", async () => {
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
env.node("state-recovery-reset-input").value = "erase my wallet";
await env.click("btn-state-recovery-reset");
expect(env.storage.read("autistmask")).toBeUndefined();
expect(env.reloaded()).toBe(1);
});
});
describe("a popup already open when the stored profile becomes unreadable", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
// on the wallet list with the last good balances, and only a reopen
// reached the recovery screen.
test("moves to the recovery screen at its next refresh", async () => {
const env = await bootPopup(unversionedValidProfile());
expect(env.visibleViews()).toEqual(["main"]);
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.text("state-recovery-problem").length).toBeGreaterThan(10);
expect(env.hidden("btn-settings")).toBe(true);
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
});
test("stops the ten-second refresh", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
const { refreshBalances } = require("../src/shared/balances");
const calls = refreshBalances.mock.calls.length;
await env.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls);
});
test("a later save does not clear what the user exported or typed", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
await env.tick();
await env.click("btn-state-recovery-export");
env.node("state-recovery-reset-input").value = "erase my";
// Such as the save of a refresh already in flight when the screen
// went up.
const { saveState } = require("../src/shared/state");
await expect(saveState()).rejects.toThrow();
await env.settle();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(env.value("state-recovery-reset-input")).toBe("erase my");
});
// The record can become readable again under this popup, erased from the
// recovery screen of another window, so a save from this one can succeed.
test("a popup opened after the record is erased elsewhere shows a screen", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
await env.storage.remove("autistmask");
const { saveState } = require("../src/shared/state");
await saveState();
const reopened = await bootPopup(env.storage.read("autistmask"));
expect(reopened.visibleViews()).toEqual(["welcome"]);
});
test("a stored current view of the recovery screen does not blank the popup", async () => {
const env = await bootPopup(
unversionedValidProfile({ currentView: "state-recovery" }),
);
expect(env.visibleViews()).toEqual(["main"]);
});
test("a transaction wait that ends under it does not replace it", async () => {
const env = await bootPopup(
unversionedValidProfile({
currentView: "wait-tx",
viewData: {
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
broadcastTime: Date.now(),
},
},
}),
);
expect(env.visibleViews()).toEqual(["wait-tx"]);
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
await env.tick();
await env.click("btn-state-recovery-export");
env.node("state-recovery-reset-input").value = "erase my";
// The test provider answers no receipt lookup, and six that fail in
// a row end the wait with an error.
for (let i = 0; i < 6; i++) await env.tick();
expect(env.text("error-tx-message")).toMatch(/could not be reached/);
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(env.value("state-recovery-reset-input")).toBe("erase my");
});
test("a storage read that fails once leaves the wallet list up", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.local.get.mockRejectedValueOnce(
new Error("IO error: storage busy"),
);
await env.tick();
// Reported as a failed save, not mistaken for an unreadable profile.
expect(env.visibleViews()).toEqual(["main"]);
expect(env.node("save-failure-banner")).not.toBeNull();
await env.tick();
expect(env.visibleViews()).toEqual(["main"]);
});
// The screen it replaces is left as any navigation leaves it: the rules
// at the top of src/popup/views/showPhrase.js and exportPrivkey.js hold
// for this way off them too.
describe("from a screen holding a secret", () => {
const PHRASE =
"abandon abandon abandon abandon abandon abandon abandon" +
" abandon abandon abandon abandon about";
afterEach(() => jest.dontMock("../src/shared/vault"));
test("a recovery phrase on screen is wiped", async () => {
jest.doMock("../src/shared/vault", () => ({
decryptWithPassword: async () => PHRASE,
}));
const env = await bootPopup(unversionedValidProfile());
require("../src/popup/views/showPhrase").show(0);
env.node("show-phrase-password").value = "password";
await env.click("btn-show-phrase-reveal");
expect(env.text("show-phrase-value")).toBe(PHRASE);
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.text("show-phrase-value")).toBe("");
});
test("a private key still being decrypted is never written", async () => {
let answer;
jest.doMock("../src/shared/vault", () => ({
decryptWithPassword: () =>
new Promise((resolve) => {
answer = resolve;
}),
}));
const env = await bootPopup(unversionedValidProfile());
require("../src/popup/views/exportPrivkey").show(0, 0);
env.node("export-privkey-password").value = "password";
const revealing = env.click("btn-export-privkey-confirm");
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.value("export-privkey-password")).toBe("");
answer(PHRASE);
await revealing;
expect(env.text("export-privkey-value")).toBe("");
});
});
});
describe("an unversioned profile that is perfectly valid", () => {
// The upgrade case. Every install in the field is in this state, and the
// popup must load it, not offer to wipe it.
test("boots to the wallet list, not the recovery screen", async () => {
const env = await bootPopup(unversionedValidProfile());
expect(env.visibleViews()).toEqual(["main"]);
expect(env.pageErrors).toEqual([]);
});
test("is migrated in place: the version is stamped, the wallet survives", async () => {
const env = await bootPopup(unversionedValidProfile());
const stored = env.storage.read("autistmask");
expect(stored.schemaVersion).toBe(1);
expect(stored.wallets).toHaveLength(1);
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
expect(stored.activeAddress).toBe(ADDRESS);
expect(stored.allowedSites).toEqual({
[ADDRESS]: ["https://dapp.example"],
});
});
});
describe("a first run with nothing in storage", () => {
test("boots to Welcome", async () => {
const env = await bootPopup(undefined);
expect(env.visibleViews()).toEqual(["welcome"]);
expect(env.pageErrors).toEqual([]);
});
});
describe("a garbage value in a field the gate does not check", () => {
// The gate refuses only what nothing can floor: the wallet list, the
// version, the network key. Everything else is normalizePersisted()'s job,
// and where that job was written as `saved.x || default` rather than a
// type check, a TRUTHY value of the wrong type walked straight through and
// threw on the first dereference — the same blank popup this issue is
// about, measured the same way. Every row below did, at the head named
// against it; none has ever been removed from this list.
//
// These belong on the floor rather than in the gate: none of these values
// carries key material, all have a sane default, and sending a user whose
// wallets are perfectly readable to an export-or-erase screen over a
// broken token list would destroy more than it saves.
//
// The CONTAINER and its ELEMENTS are separate defects. Round 2 floored the
// containers with Array.isArray(), which left every row whose container is
// a well-formed list of malformed entries still blanking the popup: the
// dereference is `t.address.toLowerCase()`, one level below the check.
const CORRUPT_FIELDS = [
// Container shapes. Observed at 2e2ecf9, before the round-2 floor:
// trackedTokens: "nope" -> views=[] "Cannot read properties of
// undefined (reading 'toLowerCase')"
// trackedTokens: 42 -> views=[] "trackedTokens is not iterable"
// trackedTokens: {a:1} -> views=[] "trackedTokens is not iterable"
// activeAddress: 42 -> views=[] "address.slice is not a
// function"
// activeAddress: {a:1} -> views=[] "address.slice is not a
// function"
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
{
name: "trackedTokens is an object",
patch: { trackedTokens: { a: 1 } },
},
{ name: "activeAddress is a number", patch: { activeAddress: 42 } },
{
name: "activeAddress is an object",
patch: { activeAddress: { a: 1 } },
},
// Element shapes: a list, holding entries that are not token records.
// Observed at a10a984, AFTER the container floor:
// [1,2] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
// [null] -> views=[] "Cannot read properties of null
// (reading 'address')"
// [{}] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
// [{address:42}] -> views=[] "t.address.toLowerCase is not a
// function"
// ["0xAA…"] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
{
name: "trackedTokens holds numbers",
patch: { trackedTokens: [1, 2] },
},
{
name: "trackedTokens holds null",
patch: { trackedTokens: [null] },
},
{
name: "trackedTokens holds a record with no address",
patch: { trackedTokens: [{}] },
},
{
name: "trackedTokens holds a record whose address is a number",
patch: { trackedTokens: [{ address: 42 }] },
},
{
name: "trackedTokens holds bare address strings",
patch: { trackedTokens: [TOKEN_ADDRESS] },
},
];
// The same defect one level deeper, inside a wallet the gate accepted.
// tokenBalances is written WHOLESALE by refreshBalances(), so the partial
// write https://git.eeqj.de/sneak/AutistMask/issues/311 names as the live
// cause of a corrupt record lands exactly here. Observed at a10a984:
// "x" -> views=[] "Cannot read properties of undefined (reading
// 'toLowerCase')" (a string iterates as characters)
// [null] -> views=[] "Cannot read properties of null (reading
// 'balance')"
// [42] -> views=[] "Cannot read properties of undefined (reading
// 'toLowerCase')"
// 42 -> views=[] "number 42 is not iterable"
const CORRUPT_TOKEN_BALANCES = [
{ name: "a string", value: "x" },
{ name: "a list holding null", value: [null] },
{ name: "a list of numbers", value: [42] },
{ name: "a number", value: 42 },
];
function profileWithTokenBalances(value) {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].tokenBalances = value;
return profile;
}
for (const { name, patch } of CORRUPT_FIELDS) {
test(`${name}: a working popup, not a blank one`, async () => {
const env = await bootPopup(
Object.assign(unversionedValidProfile(), patch),
);
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
});
}
for (const { name, value } of CORRUPT_TOKEN_BALANCES) {
test(`an address whose tokenBalances is ${name}: a working popup, not a blank one`, async () => {
const env = await bootPopup(profileWithTokenBalances(value));
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
});
}
test("the wallet is intact afterwards, and the bad value is gone", async () => {
const env = await bootPopup(
Object.assign(unversionedValidProfile(), {
trackedTokens: "nope",
activeAddress: 42,
}),
);
const stored = env.storage.read("autistmask");
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
expect(stored.trackedTokens).toEqual([]);
// Floored to null, then filled in by init()'s auto-default.
expect(stored.activeAddress).toBe(ADDRESS);
});
test("an empty activeAddress does not leave the popup with none selected", async () => {
// "" is text, so a type check alone lets it through — and init()
// auto-selects the first address only on a STRICT null, so it has to
// be floored to null rather than kept.
const env = await bootPopup(
Object.assign(unversionedValidProfile(), { activeAddress: "" }),
);
expect(env.visibleViews()).toEqual(["main"]);
expect(env.storage.read("autistmask").activeAddress).toBe(ADDRESS);
});
test("a malformed token entry is dropped, and the well-formed ones beside it survive", async () => {
const env = await bootPopup(
Object.assign(unversionedValidProfile(), {
trackedTokens: [
1,
null,
{},
{ address: 42 },
TOKEN_ADDRESS,
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
],
}),
);
const stored = env.storage.read("autistmask");
expect(stored.trackedTokens).toEqual([
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
]);
});
test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
const env = await bootPopup(
profileWithTokenBalances([
null,
42,
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]),
);
const stored = env.storage.read("autistmask");
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]);
});
});