Compare commits
1 Commits
a10a98438f
...
82425496cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 82425496cb |
15
README.md
15
README.md
@@ -1015,10 +1015,17 @@ saved data cannot be read and that nothing was signed or sent, rather than the
|
||||
generic `-32603` every request used to answer.
|
||||
|
||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||
gated, and that floor is a type check: a truthy value of the wrong type walks
|
||||
through a `saved.x || default` and throws on the first dereference, which is the
|
||||
blank popup again by a longer route. Adding a field means giving it a floor
|
||||
there or a check in the gate.
|
||||
gated. That floor is a type check for the fields something dereferences
|
||||
structurally — `trackedTokens`, each address's `tokenBalances`, `networkId`,
|
||||
`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as
|
||||
well as the container, because `[1, 2]` is a list and `t.address` is one level
|
||||
below an `Array.isArray()`. The remaining fields get a `saved.x || default` or a
|
||||
present-or-default passthrough that takes the stored value verbatim, with no
|
||||
type check at all; which field is in which category is listed in the header of
|
||||
`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
|
||||
dereferenced walks through truthiness and throws on the first read, which is the
|
||||
blank popup again by a longer route — so adding a field means choosing between
|
||||
the two by what reads it.
|
||||
|
||||
The `networkId` check is not cosmetic: that value is an object KEY into
|
||||
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
||||
|
||||
30
TODO.md
30
TODO.md
@@ -77,18 +77,24 @@ but the review is broader than any of them.
|
||||
same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned,
|
||||
unlike `-32000`..`-32006` — with a message saying the saved data cannot be
|
||||
read and that nothing was signed or sent, rather than the generic `-32603`
|
||||
that every request used to get. Two fields the gate deliberately does not
|
||||
check, `trackedTokens` and `activeAddress`, were floored on truthiness rather
|
||||
than on type and so produced the same blank popup for a truthy value of the
|
||||
wrong type; both are type-checked now. `networkById()` now throws on an id it
|
||||
does not know instead of quietly answering mainnet, and the gate's key tests
|
||||
are all own-property tests: `networkId` is an object key into
|
||||
`networkEndpoints`, so an unvalidated `"__proto__"` used to set that map's
|
||||
prototype and drop the user's endpoint silently. The three corrupt blobs from
|
||||
the issue drive the real popup entry point in `tests/stateRecovery.test.js`
|
||||
and the real worker in `tests/stateUnusableRpc.test.js`; each rendered nothing
|
||||
at all and answered `-32603` before this. `src/popup/restorableViews.js` moved
|
||||
to `src/shared/restorableViews.js`, since `persistedState.js` requires it and
|
||||
that every request used to get. Fields the gate deliberately does not check
|
||||
produced the same blank popup on their own: `trackedTokens` and
|
||||
`activeAddress` were floored on truthiness rather than on type, and
|
||||
`trackedTokens`' ENTRIES and each address's `tokenBalances` were not floored
|
||||
at all — `[1, 2]` is a list, and the dereference is `t.address.toLowerCase()`
|
||||
one level below the container. All of them are type-checked now, entries
|
||||
included, and the header of `src/shared/stateSchema.js` lists which fields of
|
||||
the record get a type check and which get a `saved.x || default` or a verbatim
|
||||
passthrough, rather than asserting a rule the module does not follow.
|
||||
`networkById()` now throws on an id it does not know instead of quietly
|
||||
answering mainnet, and the gate's key tests are all own-property tests:
|
||||
`networkId` is an object key into `networkEndpoints`, so an unvalidated
|
||||
`"__proto__"` used to set that map's prototype and drop the user's endpoint
|
||||
silently. The three corrupt blobs from the issue drive the real popup entry
|
||||
point in `tests/stateRecovery.test.js` and the real worker in
|
||||
`tests/stateUnusableRpc.test.js`; each rendered nothing at all and answered
|
||||
`-32603` before this. `src/popup/restorableViews.js` moved to
|
||||
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
|
||||
that module is in the background bundle.
|
||||
|
||||
- 2026-08-23: The background no longer reads or writes the shared `state`
|
||||
|
||||
@@ -85,12 +85,19 @@ function offerDownload(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// Residual, stated rather than left to be discovered: JSON.stringify THROWS on
|
||||
// a reference cycle or a BigInt, and Firefox's structured-clone storage can
|
||||
// hold both even though no build here writes one. That lands in the catch
|
||||
// below, so the export fails entirely and erase is the only control left on
|
||||
// the screen. Nothing here can serialize such a record; recovering one needs
|
||||
// the browser's own storage inspector.
|
||||
// Residual, stated rather than left to be discovered: structured-clone storage
|
||||
// holds values JSON does not have, and no build here writes one, but the export
|
||||
// is a funds-recovery path and what it cannot carry has to be written down.
|
||||
//
|
||||
// Loud: JSON.stringify THROWS on a reference cycle or a BigInt. That lands in
|
||||
// the catch below, so the export fails entirely and erase is the only control
|
||||
// left on the screen.
|
||||
//
|
||||
// Silent, and the worse of the two, because the box then looks complete:
|
||||
// a Date becomes its ISO string, a Map or a Set becomes {}, a property whose
|
||||
// value is undefined is dropped from the output entirely, and NaN and
|
||||
// ±Infinity become null. Nothing here can serialize any of it faithfully;
|
||||
// recovering such a record needs the browser's own storage inspector.
|
||||
async function exportRecord() {
|
||||
let text;
|
||||
try {
|
||||
|
||||
@@ -67,6 +67,31 @@ const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
|
||||
"viewStack",
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A list of token references, as everything downstream dereferences them:
|
||||
// `t.address.toLowerCase()`, with no guard of its own (src/shared/balances.js,
|
||||
// src/popup/views/helpers.js, and every view that shows a balance line).
|
||||
//
|
||||
// Both the container AND the entries, because they are separate defects. A
|
||||
// container check alone leaves a well-formed list of malformed entries walking
|
||||
// through to a dereference one level below the check, which is the same blank
|
||||
// popup: `[1, 2]` and `[{}]` are lists.
|
||||
//
|
||||
// A malformed entry is DROPPED rather than repaired: a token reference with no
|
||||
// address identifies nothing, so there is no value to repair it to, and the
|
||||
// alternative — refusing the whole record — sends a user whose wallets are
|
||||
// perfectly readable to an export-or-erase screen over a token list. An entry
|
||||
// that is a record with a text address is kept verbatim, extra fields and all.
|
||||
function tokenRefs(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(entry) => isRecord(entry) && typeof entry.address === "string",
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -126,14 +151,34 @@ function normalizePersisted(saved) {
|
||||
out.wallets = structuredClone(saved.wallets || []);
|
||||
// Derived, never trusted verbatim off storage — see loadState().
|
||||
out.hasWallet = out.wallets.length > 0;
|
||||
// An actual list is required, not merely a truthy value: everything
|
||||
// downstream iterates this and dereferences `token.address`, so a stored
|
||||
// string or object walks through a `|| []` and throws on the first read
|
||||
// — the blank popup from the issue, for a profile whose wallets are
|
||||
// perfectly fine. An empty list is a legitimate value and survives.
|
||||
out.trackedTokens = Array.isArray(saved.trackedTokens)
|
||||
? structuredClone(saved.trackedTokens)
|
||||
: [];
|
||||
// Each address's token holdings, floored to a list of token records on the
|
||||
// detached copy above. Every reader iterates it behind a `|| []` that only
|
||||
// covers an ABSENT value, and then dereferences `t.address.toLowerCase()`
|
||||
// and `t.balance` — so a stored string iterates as characters, a number
|
||||
// throws on the iterator, and a null entry throws on the field.
|
||||
//
|
||||
// This field specifically, because refreshBalances() writes it WHOLESALE
|
||||
// rather than merging into it: a write that only partly lands is the live
|
||||
// cause https://git.eeqj.de/sneak/AutistMask/issues/311 names, and this is
|
||||
// where it lands. The wallet list itself is the gate's (stateSchema.js);
|
||||
// what is below an address record is not, and gets floored here.
|
||||
if (Array.isArray(out.wallets)) {
|
||||
for (const wallet of out.wallets) {
|
||||
if (!isRecord(wallet) || !Array.isArray(wallet.addresses)) continue;
|
||||
for (const addr of wallet.addresses) {
|
||||
if (!isRecord(addr)) continue;
|
||||
addr.tokenBalances = tokenRefs(addr.tokenBalances);
|
||||
}
|
||||
}
|
||||
}
|
||||
// An actual list of token records is required, not merely a truthy value
|
||||
// and not merely a list: everything downstream iterates this and
|
||||
// dereferences `token.address`, so a stored string or object walks through
|
||||
// a `|| []`, and a list of numbers walks through an Array.isArray(), and
|
||||
// both throw on the first read — the blank popup from the issue, for a
|
||||
// profile whose wallets are perfectly fine. An empty list is a legitimate
|
||||
// value and survives.
|
||||
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
|
||||
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
||||
// floor under it. networkId is an object KEY into networkEndpoints below,
|
||||
// so a value that is not a network in networks.js must never get that far
|
||||
@@ -182,13 +227,18 @@ function normalizePersisted(saved) {
|
||||
};
|
||||
}
|
||||
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
||||
// Text or null, never anything else: this is passed to address.slice()
|
||||
// and compared against stored addresses, so a stored number or object
|
||||
// walks through a `|| null` and throws on the first render. The empty
|
||||
// string is text and is kept as stored; every reader treats it as "none
|
||||
// selected", which is what it is.
|
||||
// A non-empty address, or null, never anything else: this is passed to
|
||||
// address.slice() and compared against stored addresses, so a stored
|
||||
// number or object walks through a `|| null` and throws on the first
|
||||
// render. The empty string is text but it is not an address, and it must
|
||||
// become null rather than survive: init() auto-selects the first address
|
||||
// only on a STRICT null, so a stored "" would leave the popup with no
|
||||
// address ever selected. Nothing in src/ writes one, and this keeps the
|
||||
// behaviour the `|| null` this check replaced already had.
|
||||
out.activeAddress =
|
||||
typeof saved.activeAddress === "string" ? saved.activeAddress : null;
|
||||
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
|
||||
? saved.activeAddress
|
||||
: null;
|
||||
out.allowedSites =
|
||||
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||
? structuredClone(saved.allowedSites)
|
||||
|
||||
@@ -26,11 +26,29 @@
|
||||
//
|
||||
// What is checked HERE is what nothing downstream can floor: the wallet list,
|
||||
// the version, and the network id that keys an object. Every other field is
|
||||
// normalizePersisted()'s to make safe, and that obligation is a TYPE CHECK,
|
||||
// not a `saved.x || default` — a truthy value of the wrong type walks through
|
||||
// truthiness and throws on the first dereference, which is the same blank
|
||||
// popup, reached the long way round. Adding a field to the record means giving
|
||||
// it a floor there or a check here; do not assume a default covers it.
|
||||
// normalizePersisted()'s to make safe, and what that function does today is
|
||||
// NOT uniform. The four kinds of floor it applies, listed so a reader can tell
|
||||
// which one a given field has without reading it off:
|
||||
//
|
||||
// Type-checked, container AND entries: trackedTokens, each address's
|
||||
// tokenBalances, networkId, networkEndpoints, activeAddress, viewStack.
|
||||
// These are the fields something dereferences structurally — iterated,
|
||||
// indexed, assigned into, or .toLowerCase()'d — where a truthy value of
|
||||
// the wrong type throws on the first read. The entries matter as much as
|
||||
// the container: [1, 2] IS a list, and `t.address` is one level below the
|
||||
// Array.isArray().
|
||||
// Container shape only: allowedSites, deniedSites. A falsy value or a list
|
||||
// becomes {}; anything else is taken as stored and the entries are not
|
||||
// checked.
|
||||
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
|
||||
// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme,
|
||||
// currentView, selectedToken, viewData.
|
||||
// Present-or-default, value taken verbatim: every boolean flag,
|
||||
// dustThresholdGwei, selectedWallet, selectedAddress.
|
||||
//
|
||||
// A field added to the record needs a check here or a floor there, chosen by
|
||||
// what reads it: anything dereferenced structurally needs the type check, and
|
||||
// neither of the last two kinds is one.
|
||||
|
||||
const { isKnownNetworkId } = require("./networks");
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ const POPUP_HTML = fs.readFileSync(
|
||||
|
||||
// Fixed address, never used for anything but these tests.
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
// A fixed ERC-20 contract address, same rule.
|
||||
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
// ------------------------------------------------------------- fixtures
|
||||
|
||||
@@ -447,17 +449,28 @@ describe("a garbage value in a field the gate does not check", () => {
|
||||
// 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. Both of these did, at 2e2ecf9:
|
||||
// about, measured the same way. Every row below did, at the head named
|
||||
// against it; none has ever been removed from this list.
|
||||
//
|
||||
// trackedTokens: "nope" -> views=[] "Cannot read properties of
|
||||
// undefined (reading 'toLowerCase')"
|
||||
// activeAddress: 42 -> views=[] "address.slice is not a function"
|
||||
// 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.
|
||||
//
|
||||
// These belong on the floor rather than in the gate: neither value carries
|
||||
// key material, both 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 } },
|
||||
{
|
||||
@@ -469,8 +482,64 @@ describe("a garbage value in a field the gate does not check", () => {
|
||||
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(
|
||||
@@ -484,6 +553,17 @@ describe("a garbage value in a field the gate does not check", () => {
|
||||
});
|
||||
}
|
||||
|
||||
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(), {
|
||||
@@ -498,4 +578,53 @@ describe("a garbage value in a field the gate does not check", () => {
|
||||
// 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,9 +225,12 @@ describe("the floors under the gate, for fields the gate does not check", () =>
|
||||
// The gate's scope is what nothing can floor. Everything it lets through
|
||||
// is normalizePersisted()'s to make safe, and a floor written as
|
||||
// `saved.x || default` is not one: a truthy value of the wrong type walks
|
||||
// through it and throws on the first dereference. These two did, and
|
||||
// produced the blank popup from the issue. Type checks, not truthiness —
|
||||
// an empty list and an empty string are legitimate values and survive.
|
||||
// through it and throws on the first dereference, which produced the blank
|
||||
// popup from the issue. Nor is a container check on its own: [1, 2] is a
|
||||
// list, and the dereference is `t.address.toLowerCase()` one level below
|
||||
// it. Container AND entries, therefore — an empty list still survives.
|
||||
const TOKEN = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
test("trackedTokens that is not a list becomes an empty list", () => {
|
||||
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
||||
expect(
|
||||
@@ -236,6 +239,14 @@ describe("the floors under the gate, for fields the gate does not check", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("a trackedTokens entry that is not a token record is dropped", () => {
|
||||
for (const bad of [1, null, {}, { address: 42 }, TOKEN, [], true]) {
|
||||
expect(
|
||||
normalizePersisted({ trackedTokens: [bad] }).trackedTokens,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a real trackedTokens list survives, copied not shared", () => {
|
||||
const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] };
|
||||
|
||||
@@ -248,6 +259,57 @@ describe("the floors under the gate, for fields the gate does not check", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("a good trackedTokens entry beside a malformed one survives", () => {
|
||||
const good = { address: TOKEN, symbol: "AM", decimals: 18 };
|
||||
|
||||
expect(
|
||||
normalizePersisted({ trackedTokens: [1, null, good, {}] })
|
||||
.trackedTokens,
|
||||
).toEqual([good]);
|
||||
});
|
||||
|
||||
// Below an address record, which the gate walks but does not descend into.
|
||||
// refreshBalances() writes tokenBalances WHOLESALE, so a write that only
|
||||
// partly lands leaves exactly this field malformed.
|
||||
function walletWith(tokenBalances) {
|
||||
return {
|
||||
wallets: [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
addresses: [{ address: ADDRESS, tokenBalances }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function balancesOf(out) {
|
||||
return out.wallets[0].addresses[0].tokenBalances;
|
||||
}
|
||||
|
||||
test("an address's tokenBalances that is not a list becomes an empty list", () => {
|
||||
for (const bad of ["x", 42, true, { a: 1 }, undefined]) {
|
||||
expect(balancesOf(normalizePersisted(walletWith(bad)))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a tokenBalances entry that is not a token record is dropped", () => {
|
||||
for (const bad of [null, 42, "x", {}, { address: 42 }]) {
|
||||
expect(balancesOf(normalizePersisted(walletWith([bad])))).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a real tokenBalances entry survives, copied not shared", () => {
|
||||
const held = { address: TOKEN, symbol: "AM", balance: "2.0" };
|
||||
const saved = walletWith([held]);
|
||||
|
||||
const out = normalizePersisted(saved);
|
||||
|
||||
expect(balancesOf(out)).toEqual([held]);
|
||||
expect(balancesOf(out)[0]).not.toBe(held);
|
||||
});
|
||||
|
||||
test("activeAddress that is not text becomes null", () => {
|
||||
for (const bad of [42, true, { a: 1 }, [ADDRESS]]) {
|
||||
expect(
|
||||
@@ -256,15 +318,16 @@ describe("the floors under the gate, for fields the gate does not check", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("a real activeAddress survives, including an empty string", () => {
|
||||
test("a real activeAddress survives; the empty string becomes null", () => {
|
||||
expect(
|
||||
normalizePersisted({ activeAddress: ADDRESS }).activeAddress,
|
||||
).toBe(ADDRESS);
|
||||
// Not a useful address, but it is text and it is what was stored;
|
||||
// rewriting it to null would be normalization inventing a change.
|
||||
expect(normalizePersisted({ activeAddress: "" }).activeAddress).toBe(
|
||||
"",
|
||||
);
|
||||
// "" is text but it is not an address, and src/popup/index.js
|
||||
// auto-selects the first address only on a STRICT null — so keeping
|
||||
// the empty string would leave the popup with none ever selected.
|
||||
expect(
|
||||
normalizePersisted({ activeAddress: "" }).activeAddress,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user