// The stored-profile version stamp and the shape gate in front of it // (https://git.eeqj.de/sneak/AutistMask/issues/311). // // tests/stateRecovery.test.js covers what the USER sees when a record is // refused. This file covers what is refused and what is not, which is the // half that decides whether an upgrade brick or a false alarm ever happens: // a gate that refuses too much sends a perfectly good wallet to a wipe prompt, // and one that refuses too little is the blank popup again. const fs = require("fs"); const path = require("path"); const { STATE_SCHEMA_VERSION, StateUnusableError, assertStateUsable, migrationNeeded, stateProblem, } = require("../src/shared/stateSchema"); const { NETWORKS, UnknownNetworkError, isKnownNetworkId, networkById, } = require("../src/shared/networks"); const { normalizePersisted } = require("../src/shared/persistedState"); const { RESET_PHRASE } = require("../src/popup/views/stateRecovery"); const { makeStorageStub } = require("./support/storageStub"); const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; function validProfile(extra) { return { hasWallet: true, wallets: [ { type: "hd", name: "Wallet 1", xpub: "xpub-wallet-1", encryptedSecret: "encrypted-secret-1", nextIndex: 1, addresses: [ { address: ADDRESS, balance: "1.5", tokenBalances: [] }, ], }, ], activeAddress: ADDRESS, networkId: "mainnet", ...(extra || {}), }; } afterEach(() => { delete global.chrome; }); describe("what the gate accepts", () => { test("nothing stored at all is a first run, not a defect", () => { expect(stateProblem(undefined)).toBeNull(); expect(stateProblem(null)).toBeNull(); expect(stateProblem({})).toBeNull(); }); test("a valid profile with no version field is accepted and migrated", () => { const saved = validProfile(); expect(stateProblem(saved)).toBeNull(); expect(migrationNeeded(saved)).toBe(true); // The migration IS the stamp: version 1 is the shape that shipped // unversioned, so nothing about the record has to change. expect(normalizePersisted(saved).schemaVersion).toBe( STATE_SCHEMA_VERSION, ); expect(normalizePersisted(saved).wallets).toEqual(saved.wallets); }); test("a profile already at the current version needs no migration", () => { const saved = validProfile({ schemaVersion: STATE_SCHEMA_VERSION }); expect(stateProblem(saved)).toBeNull(); expect(migrationNeeded(saved)).toBe(false); }); test("an empty wallet list is fine", () => { expect(stateProblem({ hasWallet: false, wallets: [] })).toBeNull(); }); test("unknown extra fields alone are not a defect", () => { // Only a change in the MEANING of a stored field is a version bump, so // a field this build does not know must not be a refusal on its own — // otherwise a downgrade would wipe a working wallet. expect(stateProblem(validProfile({ somethingNew: 42 }))).toBeNull(); }); }); describe("what the gate refuses", () => { test("a record that is not the record AutistMask stores", () => { expect(stateProblem("wallet")).toMatch(/not the record/); expect(stateProblem([1, 2])).toMatch(/not the record/); }); test("a version from a newer build, naming both versions", () => { const problem = stateProblem( validProfile({ schemaVersion: STATE_SCHEMA_VERSION + 1 }), ); expect(problem).toMatch(/newer version/); expect(problem).toContain(String(STATE_SCHEMA_VERSION + 1)); expect(problem).toContain(String(STATE_SCHEMA_VERSION)); }); test("a version that is not a version at all", () => { for (const version of ["1", 1.5, 0, -1, null, {}]) { expect( stateProblem(validProfile({ schemaVersion: version })), ).toMatch(/schema version/); } }); test("wallets that is not a list", () => { expect(stateProblem({ wallets: ADDRESS })).toMatch(/not a list/); expect(stateProblem({ wallets: { 0: {} } })).toMatch(/not a list/); }); test("a wallet that is not a wallet record", () => { expect(stateProblem({ wallets: [null] })).toMatch(/wallet record/); expect(stateProblem({ wallets: [42] })).toMatch(/wallet record/); }); test("a wallet whose addresses are missing or not records", () => { expect(stateProblem({ wallets: [{ name: "Wallet 1" }] })).toMatch( /no list of addresses/, ); expect(stateProblem({ wallets: [{ addresses: [ADDRESS] }] })).toMatch( /not a record/, ); expect(stateProblem({ wallets: [{ addresses: [{}] }] })).toMatch( /no address/, ); }); test("the problem names WHICH wallet, counting from one", () => { const problem = stateProblem({ wallets: [validProfile().wallets[0], { name: "Wallet 2" }], }); expect(problem).toContain("Wallet 2"); }); test("assertStateUsable throws the sentence, not a generic error", () => { let thrown = null; try { assertStateUsable({ wallets: ADDRESS }); } catch (e) { thrown = e; } expect(thrown).toBeInstanceOf(StateUnusableError); expect(thrown.problem).toMatch(/not a list/); expect(thrown.message).toBe(thrown.problem); }); }); describe("networkId, which is used as an object key", () => { // https://git.eeqj.de/sneak/AutistMask/issues/311#issuecomment-67478: // state.networkId keys state.networkEndpoints, so a corrupt "__proto__" // sets that map's prototype instead of an own key and the user's endpoint // is silently not recorded. test("a network this build does not know is refused", () => { expect(stateProblem(validProfile({ networkId: "base" }))).toMatch( /does not know/, ); }); test('"__proto__" and "constructor" are refused, not resolved', () => { for (const id of ["__proto__", "constructor", "toString"]) { expect(stateProblem(validProfile({ networkId: id }))).toMatch( /does not know/, ); expect(isKnownNetworkId(id)).toBe(false); } }); test("the gate reads own properties only", () => { // A record whose PROTOTYPE carries the fields must not be read as // though it carried them itself: that is how a polluted prototype // would decide whether a profile is refused. const inherited = Object.create({ schemaVersion: STATE_SCHEMA_VERSION + 99, networkId: "base", wallets: "not a list", }); expect(stateProblem(inherited)).toBeNull(); }); test("normalizing never turns a stored key into a prototype", () => { // JSON can carry an own "__proto__" key, and plain assignment would // treat it as the prototype setter rather than storing an entry. const saved = JSON.parse( '{"networkId":"mainnet","networkEndpoints":' + '{"__proto__":{"rpcUrl":"https://evil.invalid"}}}', ); const out = normalizePersisted(saved); expect(Object.prototype.hasOwnProperty.call(out, "rpcUrl")).toBe(true); expect(out.rpcUrl).not.toBe("https://evil.invalid"); expect(Object.getPrototypeOf(out.networkEndpoints)).toBe( Object.prototype, ); expect({}.rpcUrl).toBeUndefined(); }); test("an unknown stored networkId never reaches the endpoint map", () => { // The floor under the gate: normalization alone must not adopt it. const out = normalizePersisted({ networkId: "__proto__" }); expect(out.networkId).toBe("mainnet"); expect(Object.keys(out.networkEndpoints)).toEqual(["mainnet"]); }); }); 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. test("trackedTokens that is not a list becomes an empty list", () => { for (const bad of ["nope", 42, true, { a: 1 }]) { expect( normalizePersisted({ trackedTokens: bad }).trackedTokens, ).toEqual([]); } }); test("a real trackedTokens list survives, copied not shared", () => { const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] }; const out = normalizePersisted(saved); expect(out.trackedTokens).toEqual(saved.trackedTokens); expect(out.trackedTokens).not.toBe(saved.trackedTokens); expect(normalizePersisted({ trackedTokens: [] }).trackedTokens).toEqual( [], ); }); test("activeAddress that is not text becomes null", () => { for (const bad of [42, true, { a: 1 }, [ADDRESS]]) { expect( normalizePersisted({ activeAddress: bad }).activeAddress, ).toBeNull(); } }); test("a real activeAddress survives, including an empty string", () => { 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( "", ); }); }); describe("networkById on an unknown id", () => { test("throws instead of quietly answering mainnet", () => { expect(() => networkById("base")).toThrow(UnknownNetworkError); expect(() => networkById(undefined)).toThrow(UnknownNetworkError); // Both of these used to answer with something truthy off the // prototype chain rather than with a network. expect(() => networkById("constructor")).toThrow(UnknownNetworkError); expect(() => networkById("__proto__")).toThrow(UnknownNetworkError); }); test("still answers every network it does know", () => { for (const id of Object.keys(NETWORKS)) { expect(networkById(id).id).toBe(id); } }); }); describe("the version stamp on the way out", () => { function loadStateModule(persisted) { jest.resetModules(); const storage = makeStorageStub( persisted ? { autistmask: persisted } : {}, ); global.chrome = { storage }; return { storage, mod: require("../src/shared/state") }; } test("the popup stamps it on a profile that had none", async () => { const { storage, mod } = loadStateModule(validProfile()); await mod.loadState(); await mod.saveState(); expect(storage.read("autistmask").schemaVersion).toBe( STATE_SCHEMA_VERSION, ); }); test("the background stamps it on a profile that had none", async () => { jest.resetModules(); const storage = makeStorageStub({ autistmask: validProfile() }); global.chrome = { storage }; const { updateState } = require("../src/background/state"); await updateState((s) => { s.lastBalanceRefresh = 1; }); expect(storage.read("autistmask").schemaVersion).toBe( STATE_SCHEMA_VERSION, ); }); test("a save refuses to write over a record it cannot read", async () => { // Another context — a newer build, or something else entirely — wrote // a record this one does not understand while this page was open. That // record is the only copy of whatever it holds, and normalizing it // back into storage would destroy it. const { storage, mod } = loadStateModule(validProfile()); await mod.loadState(); const hostile = { schemaVersion: STATE_SCHEMA_VERSION + 1 }; storage.write("autistmask", hostile); // By name rather than by constructor: jest.resetModules() above gives // the module under test its own copy of the error class, so instanceof // across that boundary would be comparing two identical classes. const thrown = await mod.saveState().then( () => null, (e) => e, ); expect(thrown && thrown.name).toBe("StateUnusableError"); expect(thrown.problem).toMatch(/newer version/); expect(storage.read("autistmask")).toEqual(hostile); }); }); describe("the typed confirmation phrase", () => { test("the markup asks for the phrase the code checks", () => { // The button is behind a phrase typed by hand. A screen that asks for // one phrase while the code compares another is an exit that cannot be // taken, on the one screen that exists to be an exit. const html = fs.readFileSync( path.join(__dirname, "..", "src", "popup", "index.html"), "utf8", ); expect(html).toContain(RESET_PHRASE); }); });