Compare commits
1 Commits
993afbc5da
...
91b36d7d5d
| Author | SHA1 | Date | |
|---|---|---|---|
| 91b36d7d5d |
24
TODO.md
24
TODO.md
@@ -55,30 +55,6 @@ undefined identifiers, which is how
|
|||||||
the active one is refused outright. The approval screen now shows the fee, gas
|
the active one is refused outright. The approval screen now shows the fee, gas
|
||||||
limit, network and nonce it vouches for
|
limit, network and nonce it vouches for
|
||||||
([#216](https://git.eeqj.de/sneak/AutistMask/issues/216)).
|
([#216](https://git.eeqj.de/sneak/AutistMask/issues/216)).
|
||||||
- 2026-08-12: The restored navigation stack is filtered against
|
|
||||||
`RESTORABLE_VIEWS` on load, truncated at the first entry the popup would not
|
|
||||||
render so that every surviving entry keeps the Back target it had. Back after
|
|
||||||
reopening can no longer land on a view the popup declined to restore, such as
|
|
||||||
`export-privkey` or `show-phrase`
|
|
||||||
([#224](https://git.eeqj.de/sneak/AutistMask/issues/224)). Restorable views in
|
|
||||||
the stack are still unhidden without being re-rendered; that is tracked
|
|
||||||
separately in ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
|
|
||||||
- 2026-08-12: One wording for a rejected password on every screen that asks for
|
|
||||||
one — the send confirmation and the delete-wallet confirmation no longer say
|
|
||||||
"Wrong password." (a fragment, which `RULES.md` Language & Labeling forbids)
|
|
||||||
and the two reveal screens no longer say "not correct", so all five
|
|
||||||
`decryptWithPassword` call sites now show the sentence the dApp approval paths
|
|
||||||
introduced. Strings only, no behaviour change, and each error container
|
|
||||||
measured at a 360px viewport in the pinned Playwright container
|
|
||||||
([#172](https://git.eeqj.de/sneak/AutistMask/issues/172)).
|
|
||||||
- 2026-08-12: Closed the empty-array hole in the end-to-end unstubbed-request
|
|
||||||
guard. `batch.every()` is vacuously true on `[]`, so a POST with body `[]` was
|
|
||||||
answered `200 []` instead of failing the suite; the guard now rejects an empty
|
|
||||||
batch, demonstrated green-before/red-after with a throwaway probe. The comment
|
|
||||||
claiming `postData()` returns `null` for undecodable bodies was corrected to
|
|
||||||
the two real paths — an absent or empty body decodes to `null`, a binary body
|
|
||||||
decodes lossily into invalid JSON
|
|
||||||
([#187](https://git.eeqj.de/sneak/AutistMask/issues/187)).
|
|
||||||
- 2026-08-12: The transaction confirmation screen has browser coverage. The
|
- 2026-08-12: The transaction confirmation screen has browser coverage. The
|
||||||
end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path
|
end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path
|
||||||
off a funded-balance fixture, and asserts the pending, funded, over-balance
|
off a funded-balance fixture, and asserts the pending, funded, over-balance
|
||||||
|
|||||||
@@ -422,10 +422,7 @@ function init(ctx) {
|
|||||||
password,
|
password,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(
|
showError("confirm-tx-password-error", "Wrong password.");
|
||||||
"confirm-tx-password-error",
|
|
||||||
"That password is incorrect. Please try again.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,7 @@ function init(_ctx) {
|
|||||||
try {
|
try {
|
||||||
await decryptWithPassword(wallet.encryptedSecret, pw);
|
await decryptWithPassword(wallet.encryptedSecret, pw);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
$("delete-wallet-flash").textContent =
|
$("delete-wallet-flash").textContent = "Wrong password.";
|
||||||
"That password is incorrect. Please try again.";
|
|
||||||
$("delete-wallet-flash").style.visibility = "visible";
|
$("delete-wallet-flash").style.visibility = "visible";
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ async function reveal() {
|
|||||||
$("export-privkey-flash").style.visibility = "hidden";
|
$("export-privkey-flash").style.visibility = "hidden";
|
||||||
} catch {
|
} catch {
|
||||||
if (!isCurrentReveal(generation)) return;
|
if (!isCurrentReveal(generation)) return;
|
||||||
fail("That password is incorrect. Please try again.");
|
fail("That password is not correct. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ async function reveal() {
|
|||||||
if (!isCurrentReveal(generation)) return;
|
if (!isCurrentReveal(generation)) return;
|
||||||
// Deliberately not the caught error: the message is fixed so that
|
// Deliberately not the caught error: the message is fixed so that
|
||||||
// nothing derived from the ciphertext or the attempt can surface.
|
// nothing derived from the ciphertext or the attempt can surface.
|
||||||
fail("That password is incorrect. Please try again.");
|
fail("That password is not correct. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
|
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
|
||||||
const { networkById } = require("./networks");
|
const { networkById } = require("./networks");
|
||||||
// Dependency-free constant module; safe to pull into a background bundle.
|
|
||||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
|
||||||
|
|
||||||
const storageApi =
|
const storageApi =
|
||||||
typeof browser !== "undefined"
|
typeof browser !== "undefined"
|
||||||
@@ -45,39 +43,6 @@ const state = {
|
|||||||
viewStack: [],
|
viewStack: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep only the leading run of stored views the popup is willing to render.
|
|
||||||
//
|
|
||||||
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
|
||||||
// behind it used to be restored verbatim, so Back could walk onto a screen
|
|
||||||
// whose content is deliberately never re-rendered — and "show-phrase" has no
|
|
||||||
// Back control to leave by. Truncating at the first such entry instead of
|
|
||||||
// splicing it out keeps the result a prefix of the stored stack, so every
|
|
||||||
// surviving entry's Back target is exactly the one it had; splicing would
|
|
||||||
// silently re-point the entry above the hole at a different screen.
|
|
||||||
//
|
|
||||||
// Filtering happens here on load rather than in saveState(): the live
|
|
||||||
// in-session stack is legitimate (the screen really is rendered while the
|
|
||||||
// popup is open), and only a load-side filter also repairs the stacks
|
|
||||||
// already in storage, including ones written before a view left the set.
|
|
||||||
function restorableStack(stored, currentView) {
|
|
||||||
// A stored stack that is missing or not an array keeps nothing, but it
|
|
||||||
// still goes through the never-empty rule below rather than returning
|
|
||||||
// early: otherwise a corrupt stack would depend on exactly the goBack()
|
|
||||||
// fallback that the explicit ["main"] exists in order not to depend on.
|
|
||||||
const source = Array.isArray(stored) ? stored : [];
|
|
||||||
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
|
|
||||||
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
|
|
||||||
// A view restored below the root still needs somewhere for Back to go.
|
|
||||||
if (
|
|
||||||
kept.length === 0 &&
|
|
||||||
currentView !== "main" &&
|
|
||||||
RESTORABLE_VIEWS.has(currentView)
|
|
||||||
) {
|
|
||||||
return ["main"];
|
|
||||||
}
|
|
||||||
return kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the network configuration for the currently selected network.
|
// Return the network configuration for the currently selected network.
|
||||||
function currentNetwork() {
|
function currentNetwork() {
|
||||||
return networkById(state.networkId);
|
return networkById(state.networkId);
|
||||||
@@ -185,7 +150,7 @@ async function loadState() {
|
|||||||
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
|
||||||
state.selectedToken = saved.selectedToken || null;
|
state.selectedToken = saved.selectedToken || null;
|
||||||
state.viewData = saved.viewData || {};
|
state.viewData = saved.viewData || {};
|
||||||
state.viewStack = restorableStack(saved.viewStack, state.currentView);
|
state.viewStack = Array.isArray(saved.viewStack) ? saved.viewStack : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,26 +297,17 @@ async function handleRpc(route, postData, opts, report) {
|
|||||||
// ethers batches by default, so the body may be an array.
|
// ethers batches by default, so the body may be an array.
|
||||||
const batch = Array.isArray(payload) ? payload : [payload];
|
const batch = Array.isArray(payload) ? payload : [payload];
|
||||||
|
|
||||||
// Anything that is not a JSON-RPC object, or a NON-EMPTY batch of
|
// Anything that is not a JSON-RPC object, or a batch of them, is not
|
||||||
// them, is not RPC at all and must be reported like any other
|
// RPC at all and must be reported like any other unrecognised
|
||||||
// unrecognised outbound traffic rather than dereferenced.
|
// outbound traffic rather than dereferenced. request.postData()
|
||||||
//
|
// returns null both for a bodyless POST and for a body Playwright
|
||||||
// The length check is not decoration: every() is vacuously true on an
|
// cannot decode as UTF-8 (sendBeacon with a Blob, or any binary
|
||||||
// empty array, so without it a POST with body [] was answered 200 []
|
// payload), so this is not an empty-string special case: it rejects
|
||||||
// and escaped the guard entirely (issue #187). No real batch is empty,
|
// every non-object payload, exactly as the catch above rejects every
|
||||||
// so nothing legitimate is caught by it.
|
// unparseable one.
|
||||||
//
|
|
||||||
// Two distinct paths land a non-RPC body here, and neither is an
|
|
||||||
// empty-string special case. playwright-core's postData() is
|
|
||||||
// `buffer.toString("utf-8") || null`, so an absent or empty body
|
|
||||||
// decodes to null, JSON.parse("null") yields null, and the type guard
|
|
||||||
// below reports it. A binary body is instead decoded LOSSILY into
|
|
||||||
// mojibake — not null — which is not valid JSON, so the catch above
|
|
||||||
// reports that one. Both end up reported; only the route differs.
|
|
||||||
if (
|
if (
|
||||||
payload === null ||
|
payload === null ||
|
||||||
typeof payload !== "object" ||
|
typeof payload !== "object" ||
|
||||||
batch.length === 0 ||
|
|
||||||
!batch.every((req) => req !== null && typeof req === "object")
|
!batch.every((req) => req !== null && typeof req === "object")
|
||||||
) {
|
) {
|
||||||
report("unstubbed request: POST " + route.request().url());
|
report("unstubbed request: POST " + route.request().url());
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ describe("a reveal that is not interrupted", () => {
|
|||||||
|
|
||||||
expect(node("export-privkey-value").textContent).toBe("");
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
expect(node("export-privkey-flash").textContent).toBe(
|
expect(node("export-privkey-flash").textContent).toBe(
|
||||||
"That password is incorrect. Please try again.",
|
"That password is not correct. Please try again.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
// One wording for one condition (issue #172).
|
|
||||||
//
|
|
||||||
// Every screen that asks for the password decrypts the vault itself, and
|
|
||||||
// each one used to write its own sentence for the same failure: the send
|
|
||||||
// confirmation and the delete-wallet confirmation said "Wrong password."
|
|
||||||
// (a fragment, which RULES.md Language & Labeling forbids), the reveal
|
|
||||||
// screens said "That password is not correct.", and the two dApp approval
|
|
||||||
// paths said "That password is incorrect." A user hitting two of those
|
|
||||||
// minutes apart had no way to tell whether the wallet meant the same
|
|
||||||
// thing.
|
|
||||||
//
|
|
||||||
// This scans the source rather than driving six views, because the
|
|
||||||
// invariant is about the set of call sites and not about any one of them:
|
|
||||||
// a seventh screen that decrypts the vault has to join the set, and a
|
|
||||||
// DOM test per view cannot notice one that was never written.
|
|
||||||
//
|
|
||||||
// The assertions are per CALL SITE, not per file. approval.js decrypts in
|
|
||||||
// two places and is where the divergence came from; a per-file check that
|
|
||||||
// only asks whether the canonical sentence appears somewhere in the file
|
|
||||||
// passes while one of those two says something else entirely. So each
|
|
||||||
// call site is read back to its own catch handler and the prose that
|
|
||||||
// handler shows the user must be the canonical sentence and nothing else
|
|
||||||
// — which fails on a novel wording, not only on a known-superseded one.
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const SRC = path.join(__dirname, "..", "src");
|
|
||||||
|
|
||||||
const CANONICAL = "That password is incorrect. Please try again.";
|
|
||||||
|
|
||||||
// Wordings this repo has actually shipped for the same condition. This is
|
|
||||||
// a secondary, whole-file sweep for stragglers outside a decrypt handler;
|
|
||||||
// divergence at a call site is caught by the exact-match assertion, which
|
|
||||||
// needs no list of phrasings to guess at.
|
|
||||||
const SUPERSEDED = [
|
|
||||||
"Wrong password.",
|
|
||||||
"That password is not correct. Please try again.",
|
|
||||||
];
|
|
||||||
|
|
||||||
function jsFilesUnder(dir) {
|
|
||||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
||||||
const full = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) return jsFilesUnder(full);
|
|
||||||
return entry.name.endsWith(".js") ? [full] : [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blank out the interior of every comment and string literal, keeping the
|
|
||||||
// offsets and line breaks, so braces can be counted without a quote or a
|
|
||||||
// commented-out block throwing the count off. The literals are returned
|
|
||||||
// alongside with the offset of their opening quote, which is how a
|
|
||||||
// message is later attributed to the handler it sits in.
|
|
||||||
function scan(source) {
|
|
||||||
const masked = source.split("");
|
|
||||||
const strings = [];
|
|
||||||
const blank = (from, to) => {
|
|
||||||
for (let k = from; k < to; k++) if (masked[k] !== "\n") masked[k] = " ";
|
|
||||||
};
|
|
||||||
let i = 0;
|
|
||||||
while (i < source.length) {
|
|
||||||
const two = source.slice(i, i + 2);
|
|
||||||
if (two === "//") {
|
|
||||||
const nl = source.indexOf("\n", i);
|
|
||||||
const stop = nl === -1 ? source.length : nl;
|
|
||||||
blank(i, stop);
|
|
||||||
i = stop;
|
|
||||||
} else if (two === "/*") {
|
|
||||||
const close = source.indexOf("*/", i + 2);
|
|
||||||
const stop = close === -1 ? source.length : close + 2;
|
|
||||||
blank(i, stop);
|
|
||||||
i = stop;
|
|
||||||
} else if (
|
|
||||||
source[i] === '"' ||
|
|
||||||
source[i] === "'" ||
|
|
||||||
source[i] === "`"
|
|
||||||
) {
|
|
||||||
const quote = source[i];
|
|
||||||
let j = i + 1;
|
|
||||||
let value = "";
|
|
||||||
while (j < source.length && source[j] !== quote) {
|
|
||||||
if (source[j] === "\\") {
|
|
||||||
value += source[j + 1];
|
|
||||||
j += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
value += source[j];
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
blank(i + 1, j);
|
|
||||||
strings.push({ offset: i, value });
|
|
||||||
i = j + 1;
|
|
||||||
} else {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { masked: masked.join(""), strings };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offset of the `{` that opens the block containing `at`, or -1.
|
|
||||||
function enclosingBlockStart(masked, at) {
|
|
||||||
let depth = 0;
|
|
||||||
for (let i = at; i >= 0; i--) {
|
|
||||||
if (masked[i] === "}") depth += 1;
|
|
||||||
else if (masked[i] === "{") {
|
|
||||||
if (depth === 0) return i;
|
|
||||||
depth -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offset just past the `}` matching the `{` at `open`.
|
|
||||||
function blockEnd(masked, open) {
|
|
||||||
let depth = 0;
|
|
||||||
for (let i = open; i < masked.length; i++) {
|
|
||||||
if (masked[i] === "{") depth += 1;
|
|
||||||
else if (masked[i] === "}") {
|
|
||||||
depth -= 1;
|
|
||||||
if (depth === 0) return i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("unterminated block");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The catch handler guarding a given decryptWithPassword call: walk out to
|
|
||||||
// the try block the call sits in, then take the catch that follows it.
|
|
||||||
function handlerSpan(masked, callOffset, label) {
|
|
||||||
const tryOpen = enclosingBlockStart(masked, callOffset);
|
|
||||||
if (tryOpen === -1 || !/\btry\s*$/.test(masked.slice(0, tryOpen)))
|
|
||||||
throw new Error(`${label}: the decrypt is not inside a try block`);
|
|
||||||
const rest = masked.slice(blockEnd(masked, tryOpen));
|
|
||||||
const catchMatch = /^\s*catch\s*(\([^)]*\)\s*)?\{/.exec(rest);
|
|
||||||
if (!catchMatch)
|
|
||||||
throw new Error(`${label}: the decrypt's try block has no catch`);
|
|
||||||
const catchOpen = blockEnd(masked, tryOpen) + catchMatch[0].length - 1;
|
|
||||||
return [catchOpen, blockEnd(masked, catchOpen)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// The prose the handler puts in front of the user. Element ids, class
|
|
||||||
// names and visibility keywords are single words; a sentence has a space
|
|
||||||
// in it, and that is the whole distinction needed here.
|
|
||||||
function handlerMessages(file, callOffset, label) {
|
|
||||||
const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
|
|
||||||
const [from, to] = handlerSpan(masked, callOffset, label);
|
|
||||||
return strings
|
|
||||||
.filter((s) => s.offset >= from && s.offset < to)
|
|
||||||
.map((s) => s.value)
|
|
||||||
.filter((v) => v.includes(" "));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The call sites are found, not listed: the file layout moves (the private
|
|
||||||
// key export was in addressDetail.js when #172 was filed and is its own
|
|
||||||
// view now), and a hardcoded list would quietly stop covering a screen it
|
|
||||||
// no longer names.
|
|
||||||
function callSites() {
|
|
||||||
const sites = [];
|
|
||||||
for (const file of jsFilesUnder(SRC)) {
|
|
||||||
if (file === path.join(SRC, "shared", "vault.js")) continue;
|
|
||||||
const { masked } = scan(fs.readFileSync(file, "utf8"));
|
|
||||||
const rel = path.relative(SRC, file).split(path.sep).join("/");
|
|
||||||
let n = 0;
|
|
||||||
let at = masked.indexOf("decryptWithPassword(");
|
|
||||||
while (at !== -1) {
|
|
||||||
n += 1;
|
|
||||||
sites.push({ file, rel, offset: at, label: `${rel} #${n}` });
|
|
||||||
at = masked.indexOf("decryptWithPassword(", at + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sites.sort((a, b) => a.label.localeCompare(b.label));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("password failure messages", () => {
|
|
||||||
const sites = callSites();
|
|
||||||
const files = [...new Set(sites.map((s) => s.file))].sort();
|
|
||||||
|
|
||||||
test("the call sites are found where they are expected", () => {
|
|
||||||
const counts = {};
|
|
||||||
for (const site of sites)
|
|
||||||
counts[site.rel] = (counts[site.rel] ?? 0) + 1;
|
|
||||||
expect(counts).toEqual({
|
|
||||||
"popup/views/approval.js": 2,
|
|
||||||
"popup/views/confirmTx.js": 1,
|
|
||||||
"popup/views/deleteWallet.js": 1,
|
|
||||||
"popup/views/exportPrivkey.js": 1,
|
|
||||||
"popup/views/showPhrase.js": 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the canonical message is a full sentence", () => {
|
|
||||||
expect(CANONICAL).toMatch(/^[A-Z][^]*\.$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Exact equality, per call site: a message that is merely different
|
|
||||||
// rather than known-obsolete fails here too, which a scan for historic
|
|
||||||
// wordings cannot do.
|
|
||||||
test.each(sites.map((s) => [s.label, s]))(
|
|
||||||
"%s answers a rejected password with the canonical sentence",
|
|
||||||
(label, site) => {
|
|
||||||
expect(handlerMessages(site.file, site.offset, label)).toEqual([
|
|
||||||
CANONICAL,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each(files.map((f) => [path.relative(SRC, f), f]))(
|
|
||||||
"%s carries no superseded wording",
|
|
||||||
(_rel, file) => {
|
|
||||||
const source = fs.readFileSync(file, "utf8");
|
|
||||||
for (const old of SUPERSEDED) expect(source).not.toContain(old);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -159,113 +159,3 @@ describe("hideSpoofedSymbols persistence", () => {
|
|||||||
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
|
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
|
||||||
// behind it was restored verbatim, so Back could still walk onto a screen
|
|
||||||
// whose content is deliberately never re-rendered — and "show-phrase" has no
|
|
||||||
// Back control of its own to leave by. The stack is filtered on load, at the
|
|
||||||
// first entry the popup would not render, and everything above it goes too:
|
|
||||||
// those entries were reached THROUGH the dropped one.
|
|
||||||
describe("restored viewStack is filtered against RESTORABLE_VIEWS", () => {
|
|
||||||
const NON_RESTORABLE = ["export-privkey", "show-phrase"];
|
|
||||||
|
|
||||||
function restoredStack(viewStack, currentView = "settings") {
|
|
||||||
return loadModuleWith({
|
|
||||||
wallets: oneWallet(),
|
|
||||||
currentView,
|
|
||||||
viewStack,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a non-restorable view at the top of the stack is dropped", async () => {
|
|
||||||
const { mod } = restoredStack(["main", "address", "export-privkey"]);
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual(["main", "address"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a non-restorable view in the middle truncates the stack there", async () => {
|
|
||||||
const { mod } = restoredStack(["main", "show-phrase", "address"]);
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual(["main"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Truncating a stack rooted at a non-restorable view leaves nothing, and
|
|
||||||
// the restored view still needs somewhere for Back to go.
|
|
||||||
test("a non-restorable view at the bottom leaves main to go back to", async () => {
|
|
||||||
const { mod } = restoredStack(["export-privkey", "address", "receive"]);
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual(["main"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no restored stack retains a secret-bearing view", async () => {
|
|
||||||
for (const view of NON_RESTORABLE) {
|
|
||||||
const { mod } = restoredStack(["main", "address", view, "receive"]);
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).not.toContain(view);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// The rule is "views the popup will render", not a blocklist of the two
|
|
||||||
// secret screens: a name no longer in the set (or never a view at all)
|
|
||||||
// has to go the same way.
|
|
||||||
test("a name that is not a restorable view at all is dropped", async () => {
|
|
||||||
const { mod } = restoredStack(["main", "welcome", "address"]);
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual(["main"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restorable entries are kept verbatim. That they are then unhidden
|
|
||||||
// without being re-rendered is a separate defect, tracked in #268; this
|
|
||||||
// filter is only about views the popup declined to restore.
|
|
||||||
test("an ordinary restorable stack is restored unchanged", async () => {
|
|
||||||
const stack = ["main", "address", "address-token"];
|
|
||||||
const { mod } = restoredStack(stack);
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual(stack);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("restoring onto main keeps the stack empty", async () => {
|
|
||||||
const { mod } = restoredStack(["show-phrase"], "main");
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// main is not the only view that gets no ["main"] beneath it: restoreView()
|
|
||||||
// will not reopen onto a non-restorable view either, so nothing is left for
|
|
||||||
// Back to sit under and the stack stays empty.
|
|
||||||
test("restoring onto a view the popup will not reopen keeps the stack empty", async () => {
|
|
||||||
const { mod } = restoredStack(["export-privkey"], "show-phrase");
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Not an array means nothing survives, but the never-empty rule still
|
|
||||||
// applies: a corrupt stack must not leave a restored view with no Back
|
|
||||||
// target of its own.
|
|
||||||
test("a stack that is not an array still gets main beneath a restored view", async () => {
|
|
||||||
const { mod } = restoredStack("main");
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual(["main"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a stack that is not an array loads as empty under main", async () => {
|
|
||||||
const { mod } = restoredStack({ 0: "main" }, "main");
|
|
||||||
await mod.loadState();
|
|
||||||
expect(mod.state.viewStack).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filtering belongs on load, not on save: the live in-session stack is
|
|
||||||
// legitimate — the user really is one Back away from a screen that is
|
|
||||||
// rendered right now — and only a load-side filter also cleans the
|
|
||||||
// stacks already sitting in storage.
|
|
||||||
test("saveState persists the live stack verbatim", async () => {
|
|
||||||
const { mod, set } = loadModuleWith(null);
|
|
||||||
mod.state.viewStack = ["main", "address", "export-privkey"];
|
|
||||||
await mod.saveState();
|
|
||||||
expect(set).toHaveBeenCalledWith({
|
|
||||||
autistmask: expect.objectContaining({
|
|
||||||
viewStack: ["main", "address", "export-privkey"],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user