Ctrl K
ComunidadGuías…

tests/e2e/vault.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* End-to-end encryption in real browsers, against a real server, with several devices at once.
     REPO=/path/to/Wally PYTHON=/path/to/python PORT=8897 node vault.mjs
   What the server stores is checked from outside with tests/vault_check.py, which opens it with a
   second implementation of the crypto. Screenshots go to SHOTS (a folder) when it is set. */
import { chromium } from "playwright";
import { spawn, execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash, createHmac } from "node:crypto";

const REPO = process.env.REPO || process.cwd();
const PYTHON = process.env.PYTHON || "python3";
const PORT = +(process.env.PORT || 8897);
const SHOTS = process.env.SHOTS || "";
if (SHOTS && !existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true });
const PAUSE = 6;

let failures = 0;
const ok = (cond, msg) => { if (cond) console.log("  ok  " + msg); else { failures++; console.log("  FAIL " + msg); } };
const section = (s) => console.log("\n" + s);

/* ---------- the server ---------- */
let server = null;
const data = mkdtempSync(join(tmpdir(), "wally-vault-"));
const DB = join(data, "wally.db");
const base = "http://localhost:" + PORT;
async function startServer() {
  const env = { ...process.env, WALLY_DATA: data, WALLY_PAUSE_SECONDS: String(PAUSE) };
  delete env.DEMO;
  server = spawn(PYTHON, ["-m", "uvicorn", "utils.dev_server:app", "--port", String(PORT)], { cwd: REPO, env, stdio: ["ignore", "ignore", "pipe"] });
  server.stderr.on("data", (d) => { if (/Traceback|ERROR/.test(String(d))) process.stderr.write("[server] " + d); });
  for (let i = 0; i < 80; i++) {
    try { const r = await fetch(base + "/api/health"); if (r.ok) return; } catch (e) { /* not yet */ }
    await new Promise((r) => setTimeout(r, 200));
  }
  throw new Error("server did not start");
}
async function stopServer(signal = "SIGTERM") {
  if (!server) return;
  const done = new Promise((r) => server.once("exit", r));
  server.kill(signal);
  await done;
  server = null;
}
const digest = () => createHash("sha256").update(readFileSync(DB)).digest("hex");
/* What whoever runs the server types when the kit is lost too: python3 -m api.signin_off */
const signinOff = () => { const r = spawnSync(PYTHON, ["-m", "api.signin_off"], { cwd: REPO, env: { ...process.env, WALLY_DATA: data }, encoding: "utf8" }); return { code: r.status, text: (r.stdout || "") + (r.stderr || "") }; };
/* The plain kit, checked by the server (no browser needed) */
const plainRecover = async (person_id, key) => (await fetch(base + "/api/kit/recover", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ person_id, key }) })).status;
const check = (...args) => JSON.parse(execFileSync(PYTHON, [join(REPO, "tests/vault_check.py"), ...args], { cwd: REPO, encoding: "utf8", maxBuffer: 1 << 28 }));

/* A client of the API with its own cookies, for setting things up */
function apiClient() {
  let cookie = "";
  const fn = async (method, path, body, headers) => {
    const r = await fetch(base + "/api" + path, { method, headers: { "content-type": "application/json", cookie, ...(headers || {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
    for (const c of r.headers.getSetCookie ? r.headers.getSetCookie() : []) {
      const kv = c.split(";")[0], k = kv.split("=")[0];
      cookie = cookie.split("; ").filter((x) => x && !x.startsWith(k + "=")).concat(/=$/.test(kv) || /Max-Age=0/i.test(c) ? [] : [kv]).join("; ");
    }
    const text = await r.text();
    let out; try { out = JSON.parse(text); } catch (e) { out = text; }
    if (!r.ok) { const e = new Error(method + " " + path + " " + r.status + " " + text); e.status = r.status; throw e; }
    return out;
  };
  return fn;
}

/* ---------- browsers ---------- */
const browser = await chromium.launch();
const EXPECTED = /status of (400|401|403|404|409|410|429|503)/;
async function device({ width = 1280, height = 860, clock } = {}) {
  const ctx = await browser.newContext({ viewport: { width, height }, serviceWorkers: "block" });
  const page = await ctx.newPage();
  if (clock) await page.clock.install({ time: clock });
  page.errors = [];
  page.on("console", (m) => { if (m.type() === "error" && !EXPECTED.test(m.text())) page.errors.push(m.text()); });
  page.on("pageerror", (e) => page.errors.push("PAGEERROR " + e.message));
  page.on("dialog", (d) => d.dismiss().catch(() => {}));
  return page;
}
const shot = async (page, name) => { if (SHOTS) { await page.waitForTimeout(300); await page.screenshot({ path: join(SHOTS, name + ".png") }); } };
const appShown = (page, timeout = 15000) => page.waitForSelector("#app:not([hidden])", { timeout }).then(() => true, () => false);
// Unlock: their face and name, the password field and Forgot password?, as at sign-in
const unlockShown = (page, timeout = 15000) => page.waitForFunction(() => !document.getElementById("gate").hidden && document.getElementById("vg-forgot") && document.querySelector("#gate input#vg-in[type=password]"), null, { timeout }).then(() => true, () => false);
const gateShown = (page, text, timeout = 15000) => page.waitForFunction((t) => !document.getElementById("gate").hidden && document.getElementById("gate").innerText.includes(t), text, { timeout }).then(() => true, () => false);
// Back at sign-in: their face and name, and the password field
const atPassword = (page, name, timeout = 15000) => page.waitForFunction((n) => !document.getElementById("gate").hidden && document.getElementById("si-in") && document.getElementById("gate-h").textContent === n, name, { timeout }).then(() => true, () => false);
const noErrors = (page, what) => ok(page.errors.length === 0, what + ": no console errors" + (page.errors.length ? " — " + page.errors.join(" | ") : ""));
/* Run in the page, with core.js and the vault session, the same module instances the app uses */
const inPage = (page, fn, arg) => page.evaluate(async ([src, a]) => {
  const core = await import("/js/core.js");
  const session = await import("/js/vault/session.js");
  const store = await import("/js/vault/store.js");
  return (0, eval)("(" + src + ")")({ core, session, store }, a);
}, [fn.toString(), arg]);
/* Wait until fn (run in the page) returns true */
async function until(page, fn, arg, timeout = 10000) {
  const end = Date.now() + timeout;
  while (Date.now() < end) { if (await inPage(page, fn, arg).catch(() => false)) return true; await page.waitForTimeout(150); }
  return false;
}
const ledger = (page) => inPage(page, async ({ core }) => (await core.GET("/ledger")).rows.map((r) => JSON.stringify(r)).sort());
const syncPage = (page) => inPage(page, async ({ session }) => session.syncNow());
const hasKeys = (page) => page.evaluate(() => new Promise((resolve) => {
  const r = indexedDB.open("wally-vault");
  r.onupgradeneeded = () => { r.transaction.abort(); resolve(false); };
  r.onerror = () => resolve(false);
  r.onsuccess = () => {
    const d = r.result;
    if (!d.objectStoreNames.contains("keys")) { d.close(); return resolve(false); }
    const g = d.transaction("keys").objectStore("keys").get("household");
    g.onsuccess = () => { d.close(); resolve(!!g.result); };
    g.onerror = () => { d.close(); resolve(false); };
  };
}));

async function signIn(page, personId, password) {
  await page.goto(base + "/", { waitUntil: "networkidle" });
  await page.waitForSelector("#si-in, [data-prof]", { timeout: 10000 });
  if (await page.locator('[data-prof="' + personId + '"]').count()) await page.click('[data-prof="' + personId + '"]');
  else {
    const name = personId === 1 ? "Alex" : "Ana";
    if (!(await page.locator("#gate").innerText()).includes(name)) { await page.click("#si-not"); await page.click('[data-prof="' + personId + '"]'); }
  }
  await page.fill("#si-in", password);
  await page.click("#si-go");
}

/* Two-step codes, as an authenticator app works them out (RFC 6238) */
function base32(str) {
  const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  let bits = "";
  for (const ch of str.replace(/[\s=]/g, "").toUpperCase()) bits += abc.indexOf(ch).toString(2).padStart(5, "0");
  const out = [];
  for (let i = 0; i + 8 <= bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8), 2));
  return Buffer.from(out);
}
function totpAt(secret, counter) {
  const buf = Buffer.alloc(8);
  buf.writeBigUInt64BE(BigInt(counter));
  const h = createHmac("sha1", base32(secret)).update(buf).digest(), o = h[h.length - 1] & 15;
  return String(((h.readUInt32BE(o) & 0x7fffffff) % 1e6)).padStart(6, "0");
}
/* The server takes each 30-second code once for the whole household: the next one it will take */
let lastCounter = -1;
async function freshCode(secret) {
  for (;;) {
    const now = Math.floor(Date.now() / 30000);
    for (const c of [now, now + 1]) if (c > lastCounter) { lastCounter = c; return totpAt(secret, c); }
    await new Promise((r) => setTimeout(r, 1000));
  }
}
const HP1 = "house-pass-1", HP2 = "house-pass-2";

const TODAY = new Date().toISOString().slice(0, 10);
const daysAgo = (n) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10);
const WORDS = ["Mercadona", "Farmacia", "Therapy", "Café", "AMAZON", "Netflix", "holiday", "weekly", "Kids", "Fruiteria", "23.45"];

try {
  await startServer();
  /* ================= a plain household, with some of everything ================= */
  section("A plain household");
  const setup = apiClient();
  const ALEX = (await setup("POST", "/people", { name: "Alex" })).id;
  const ANA = (await setup("POST", "/people", { name: "Ana" })).id;
  const as = { "x-wally-person": String(ALEX) };
  await setup("POST", "/transactions", { name: "Mercadona", category: "Groceries", amount: "23.45", date: daysAgo(2), paid_by: ALEX, note: "Café y pan", tags: ["weekly"] }, as);
  await setup("POST", "/transactions", { name: "Farmacia Sant Jordi", category: "Healthcare", amount: "7.10", date: daysAgo(1), person_id: ANA }, as);
  await setup("POST", "/transactions", { name: "Dentist", category: "Therapy", amount: "80.00", date: daysAgo(3), shares: { [ALEX]: 2000, [ANA]: 6000 } }, as);
  await setup("POST", "/transactions", { name: "Salary", category: "Income", amount: "2450", type: "income", date: daysAgo(5), person_id: ALEX }, as);
  await setup("POST", "/recurring", { name: "Netflix", category: "Entertainment", amount: "13.99", startDate: daysAgo(70), frequency: "monthly", paid_by: ANA }, as);
  await setup("POST", "/import/commit", { rows: [{ date: daysAgo(4), name: "AMAZON MKTPLACE 99", amount: "-15.20", import_ref: "ref-1", action: "add", category: "Shopping" }] }, as);
  await setup("POST", "/categories", { name: "Kids", color: 3 }, as);
  await setup("POST", "/tags", { name: "holiday" }, as);
  ok(!("kit" in (await setup("PUT", "/signin", { enabled: true, password: HP1 }, as))), "sign-in on: no kit comes with it, optional without encryption");
  // Made in Settings, with the password: so encrypting has one to replace
  const plainKit = (await setup("POST", "/kit", { current: HP1 })).key;
  ok(await plainRecover(ANA, plainKit) === 200, "a kit made on its own is checked by the server");
  ok(check("plain", DB).items.length >= 15, "the household has some of everything, in the clear");

  /* ================= Alex turns encryption on, in the browser ================= */
  section("Turning encryption on");
  const alex1 = await device();
  await signIn(alex1, ALEX, HP1);
  ok(await appShown(alex1), "Alex signs in (encryption off)");
  const plainLedger = await ledger(alex1);
  // Alex's second device, open on the plain household while encryption is turned on
  const alex2 = await device({ width: 390, height: 844 });
  await signIn(alex2, ALEX, HP1);
  ok(await appShown(alex2), "Alex signs in on a phone too");
  // Ana, signed in on her own browser while encryption is turned on
  const ana0 = await device();
  await signIn(ana0, ANA, HP1);
  ok(await appShown(ana0), "Ana signs in with the same household password (encryption off)");

  await alex1.goto(base + "/settings/privacy", { waitUntil: "networkidle" });
  await shot(alex1, "privacy-off");
  // What the plain database holds just before encryption
  const plainBefore = check("plain", DB).items;
  await alex1.click("#pv-toggle");
  const onText = await alex1.locator("#dlg").innerText();
  ok(onText.includes("Turn On Encryption") && onText.includes("Only your household’s devices will read your data.") && onText.includes("Sign-in stays on") && !onText.includes("code"), "the sheet says what changes in one sentence and a footnote: no codes");
  await alex1.fill("#pv-pw", "wrong-password");
  await alex1.click("[data-dlg-ok]");
  await alex1.waitForFunction(() => !document.getElementById("dlg-err").hidden, null, { timeout: 15000 });
  ok((await alex1.locator("#dlg-err").innerText()).length > 0 && !check("grep", DB, "VAULT_ID").VAULT_ID, "a wrong password changes nothing");
  await alex1.fill("#pv-pw", HP1);
  await alex1.click("[data-dlg-ok]");
  await alex1.waitForFunction(() => document.querySelector("#dlg #dlg-t") && document.querySelector("#dlg #dlg-t").textContent.includes("Emergency Kit"), null, { timeout: 60000 });
  await shot(alex1, "kit-dialog");
  const alexKey = (await alex1.locator("#dlg .w-kitkey").innerText()).replace(/\s+/g, "-");
  ok(/^([0-9A-Z]{4}-){11}[0-9A-Z]{4}$/.test(alexKey), "the Emergency Kit shows a 48-character recovery key, in 12 groups");
  ok(await alex1.locator("#dlg [data-dlg-ok]").isDisabled(), "Done waits until the kit is saved");
  await alex1.keyboard.press("Escape");
  ok(await alex1.locator("#dlg .w-kitkey").count() === 1, "Escape doesn't close the kit");
  await alex1.click("#kit-saved");
  ok((await alex1.locator("#dlg").innerText()).includes("this recovery key gets you back in"), "one Emergency Kit, for the household");
  await alex1.click("#dlg [data-dlg-ok]");
  await alex1.waitForLoadState("networkidle");
  ok(await appShown(alex1, 20000), "Wally opens again, encrypted");
  ok(await alex1.locator("#dlg [data-code]").count() === 0 && !(await alex1.locator("#dlg").isVisible()), "no codes for anyone after the kit");
  ok(await hasKeys(alex1), "the household key is kept on this browser");
  ok(await plainRecover(ALEX, plainKit) === 401, "the kit from before encryption stops working");

  const after = check("open", DB, HP1);
  ok(JSON.stringify(after.items) === JSON.stringify(plainBefore), "every item, opened with Alex’s password from outside the browser, is exactly what was there before (" + after.items.length + ")");
  ok(after.counts.transaction === 0 && after.counts.category === 0 && after.counts.tag === 0 && after.counts.recurringtransaction === 0, "no table holds data in the clear");
  const hits = Object.entries(check("grep", DB, ...WORDS)).filter(([, v]) => v).map(([k]) => k);
  ok(hits.length === 0, "no name, note, category, tag or amount is anywhere in wally.db" + (hits.length ? " — found " + hits.join(", ") : ""));
  ok(JSON.stringify(await ledger(alex1)) === JSON.stringify(plainLedger), "the app shows the same ledger as before, row for row");
  ok(!existsSync(join(data, "wally.prev.db")) && !existsSync(join(data, "wally.next.db")) && !existsSync(join(data, "conversion.json")), "no old file, new file or journal is left");
  noErrors(alex1, "turning on");

  /* ================= encryption turned on elsewhere: Unlock once ================= */
  section("A device that was open meanwhile");
  await alex2.reload({ waitUntil: "networkidle" });
  ok(await unlockShown(alex2), "Alex’s phone asks to unlock, once");
  await shot(alex2, "unlock-phone");
  await alex2.fill("#vg-in", "nope-nope");
  await alex2.click("#vg-go");
  await alex2.waitForFunction(() => document.getElementById("vg-msg").textContent.trim().length > 0, null, { timeout: 15000 });
  ok((await alex2.locator("#vg-msg").innerText()).includes("isn’t right"), "a wrong password says so");
  await alex2.fill("#vg-in", HP1);
  await alex2.click("#vg-go");
  ok(await appShown(alex2), "the right one opens it");
  ok(JSON.stringify(await ledger(alex2)) === JSON.stringify(plainLedger), "the phone has the same ledger");
  await alex2.reload({ waitUntil: "networkidle" });
  ok(await appShown(alex2), "and opens without asking again");
  noErrors(alex2, "unlocking");
  // Ana's browser, signed in before encryption: the same unlock, with the same password
  await ana0.reload({ waitUntil: "networkidle" });
  ok(await unlockShown(ana0), "Ana’s browser, already signed in, asks to unlock once");
  ok((await ana0.locator("#gate-h").innerText()) === "Ana" && (await ana0.getAttribute("#vg-in", "placeholder")) === "Household password", "her face and name, with the household password");
  await shot(ana0, "unlock-ana");
  await ana0.fill("#vg-in", HP1);
  await ana0.click("#vg-go");
  ok(await appShown(ana0), "the same password opens it for Ana");
  ok(JSON.stringify(await ledger(ana0)) === JSON.stringify(plainLedger), "with the same ledger");
  ok(await hasKeys(ana0), "and the household key is kept on her browser");
  noErrors(ana0, "Ana unlocking");

  /* ================= a second person, on a new browser ================= */
  section("A second person");
  const ana1 = await device();
  await signIn(ana1, ANA, HP1);
  ok(await appShown(ana1), "Ana signs in on a new browser with the household password, unlocked in the same step");
  ok(!(await ana1.locator("#gate").isVisible()), "no code, no new password, no Emergency Kit of her own");
  ok(JSON.stringify(await ledger(ana1)) === JSON.stringify(plainLedger), "with the same ledger");
  const st1 = await inPage(ana1, async ({ core }) => core.GET("/auth/status"));
  ok(st1.me && st1.me.id === ANA, "as Ana");
  noErrors(ana1, "Ana’s first encrypted sign-in");

  /* ================= several devices ================= */
  section("Two devices, the same household");
  // A transaction added through the app on one device shows on the other
  await alex1.goto(base + "/transactions", { waitUntil: "networkidle" });
  await alex1.keyboard.press("n");
  await alex1.waitForSelector("#add-modal:not([hidden])");
  const created = await inPage(alex1, async ({ core }) => core.POST("/transactions", { name: "Fruiteria Rosa", category: "Groceries", amount: "12.30", date: new Date().toISOString().slice(0, 10), tags: ["market"] }));
  await alex1.keyboard.press("Escape");
  ok(created && created.id && created.amount === "12.30", "a transaction is saved, encrypted");
  ok(!check("grep", DB, "Fruiteria").Fruiteria, "and its name is nowhere on the server");
  await ana1.evaluate(() => window.dispatchEvent(new Event("focus")));
  ok(await until(ana1, async ({ core }, id) => core.state.tx.some((t) => t.id === id && t.n === "Fruiteria Rosa"), created.id), "Ana’s browser shows it as soon as it gets focus");
  ok(await ana1.locator("#main").innerText().then((x) => x.length > 0), "and the page is drawn again");

  // The same transaction edited on two devices: different fields both kept
  const t1 = created.id;
  await syncPage(alex2);
  await inPage(alex1, async ({ core }, id) => core.PUT("/transactions/" + id, { name: "Fruiteria Rosa Mercat" }), t1);
  const merged = await inPage(alex2, async ({ core }, id) => core.PUT("/transactions/" + id, { amount: "14.00" }), t1);
  ok(merged.name === "Fruiteria Rosa Mercat" && merged.amount === "14.00", "edited on two devices: one changed the name, the other the amount, and both are kept");
  // The same field: the later save wins
  await syncPage(ana1);
  await inPage(alex1, async ({ core }, id) => core.PUT("/transactions/" + id, { note: "first" }), t1);
  const later = await inPage(ana1, async ({ core }, id) => core.PUT("/transactions/" + id, { note: "second" }), t1);
  ok(later.note === "second" && later.name === "Fruiteria Rosa Mercat" && later.amount === "14.00", "the same field on two devices: the later save wins, the rest stays");
  // No name: blank from the add sheet, sealed blank, and blank on the other device
  await alex1.keyboard.press("n");
  await alex1.waitForSelector("#add-modal:not([hidden])");
  await alex1.fill("#add-amt", "4.20");
  await alex1.click("#add-cat");
  await alex1.fill("#add-cat", "Groceries");
  await alex1.keyboard.press("Enter");
  await alex1.click("#add-go");
  const noName = async ({ core }) => core.state.tx.some((t) => t.ac === 420 && t.n === "" && t.c === "Groceries");
  ok(await until(alex1, noName), "with no name it is saved blank, not with the category as its name");
  await syncPage(alex1);
  const sealedBlank = () => check("open", DB, HP1).items.map((x) => JSON.parse(x)).some((x) => x.k === "tx" && x.cents === 420 && x.name === "" && x.category === "Groceries");
  let blankSealed = false;
  for (let i = 0; i < 40 && !(blankSealed = sealedBlank()); i++) await alex1.waitForTimeout(250);
  ok(blankSealed && check("open", DB, HP1).counts.transaction === 0, "opened from outside the browser, its name is empty, and nothing is in the clear");
  await ana1.evaluate(() => window.dispatchEvent(new Event("focus")));
  ok(await until(ana1, noName), "Ana’s browser has it, blank");
  const cleared = await inPage(alex1, async ({ core }, id) => core.PUT("/transactions/" + id, { name: "" }), t1);
  ok(cleared.name === "" && cleared.amount === "14.00", "a name can be cleared, and the rest stays");
  await inPage(alex1, async ({ core }) => core.DEL("/transactions/" + core.state.tx.find((t) => t.ac === 420).id));
  // Edit against delete
  await syncPage(ana1);
  await inPage(alex1, async ({ core }, id) => core.DEL("/transactions/" + id), t1);
  const gone = await inPage(ana1, async ({ core }, id) => { try { await core.PUT("/transactions/" + id, { note: "too late" }); return "saved"; } catch (e) { return e.message; } }, t1);
  ok(gone === "Deleted on another device.", "editing what another device deleted says “Deleted on another device.”");
  ok(!(await inPage(ana1, async ({ core }, id) => (await core.GET("/transactions/id/" + id)).length, t1)), "and it stays deleted");

  // A category renamed on one device while another adds to it
  await syncPage(ana1);
  const cats = await Promise.all([
    inPage(alex1, async ({ core }) => core.PUT("/categories/Therapy", { name: "Health care" })),
    inPage(ana1, async ({ core }) => core.POST("/transactions", { name: "Psychologist", category: "Therapy", amount: "60", date: new Date().toISOString().slice(0, 10) })),
  ]);
  await syncPage(alex1); await syncPage(ana1);
  const [la, lb] = [await ledger(alex1), await ledger(ana1)];
  ok(JSON.stringify(la) === JSON.stringify(lb), "after a rename and an add at the same moment, both devices agree (" + la.length + " rows)");
  ok(cats[0].name === "Health care", "the rename went through");

  // Recurring charges due on two devices at once: each date added once
  const rule = await inPage(alex1, async ({ core }) => core.POST("/recurring", { name: "Gym", category: "Healthcare", amount: "39", startDate: new Date(Date.now() - 2 * 864e5).toISOString().slice(0, 10), frequency: "daily", person_id: 1 }));
  ok(rule && rule.last_run, "a daily rule is made, with its charges up to today");
  const ahead = Date.now() + 3 * 864e5;
  const [p1, p2] = [await device({ clock: ahead }), await device({ clock: ahead })];
  await Promise.all([signIn(p1, ALEX, HP1), signIn(p2, ALEX, HP1)]);
  await Promise.all([appShown(p1, 30000), appShown(p2, 30000)]);
  await p1.waitForTimeout(2500);
  await Promise.all([syncPage(p1), syncPage(p2)]);
  const gym = await inPage(p1, async ({ core }, rid) => (await core.GET("/ledger")).rows.filter((r) => r[12] === rid).map((r) => r[1]).sort(), rule.id);
  const gym2 = await inPage(p2, async ({ core }, rid) => (await core.GET("/ledger")).rows.filter((r) => r[12] === rid).map((r) => r[1]).sort(), rule.id);
  ok(gym.length === 6 && new Set(gym).size === 6 && JSON.stringify(gym) === JSON.stringify(gym2), "two devices opening three days later add each due charge once (" + gym.join(", ") + ")");
  noErrors(p1, "device three days later"); noErrors(p2, "the other device three days later");
  await p1.context().close(); await p2.context().close();

  /* ================= when it asks again ================= */
  section("When Wally asks again");
  // Storage cleared, session still valid (Safari's seven days): the password only
  await alex2.evaluate(() => new Promise((r) => { const q = indexedDB.deleteDatabase("wally-vault"); q.onsuccess = q.onerror = q.onblocked = () => r(); }));
  await alex2.reload({ waitUntil: "networkidle" });
  ok(await unlockShown(alex2), "storage cleared: the phone asks for the password only");
  await alex2.fill("#vg-in", HP1);
  await alex2.click("#vg-go");
  ok(await appShown(alex2), "and opens");
  await syncPage(alex1);
  ok(JSON.stringify(await ledger(alex2)) === JSON.stringify(await ledger(alex1)), "downloading everything again gives the same ledger");

  // Lock when Wally closes
  await alex2.goto(base + "/settings/privacy", { waitUntil: "networkidle" });
  await shot(alex2, "privacy-phone");
  await alex2.waitForSelector("#pv-lock", { timeout: 8000 });
  await alex2.click("#pv-lock");
  await alex2.waitForTimeout(300);
  ok(!(await hasKeys(alex2)), "“Lock when Wally closes” leaves no key on this browser");
  await alex2.reload({ waitUntil: "networkidle" });
  ok(await unlockShown(alex2), "so it asks each time Wally opens");
  await alex2.fill("#vg-in", HP1);
  await alex2.click("#vg-go");
  ok(await appShown(alex2), "and opens, with the key in memory only");
  ok(!(await hasKeys(alex2)), "still no key stored");
  // Moving around inside Wally keeps it open; only a new page load asks again
  await alex2.evaluate(() => { history.pushState(null, "", "/settings/privacy"); dispatchEvent(new PopStateEvent("popstate")); });
  await alex2.waitForSelector("#pv-lock", { timeout: 8000 });
  ok(await appShown(alex2, 1000), "moving between pages keeps it unlocked");
  await alex2.click("#pv-lock");
  await alex2.waitForTimeout(300);
  ok(await hasKeys(alex2), "turning it off keeps the key on this browser again");

  // Sign out others: every other browser in the household, Ana's too; their next contact wipes them
  await alex1.goto(base + "/settings/security/browsers", { waitUntil: "networkidle" });
  await alex1.waitForSelector('[data-act="others"]:not([disabled])', { timeout: 10000 });
  await alex1.waitForFunction(() => /Ana · Last seen/.test(document.getElementById("set-pane").innerText), null, { timeout: 10000 }).catch(() => {});
  ok(/Ana · Last seen/.test(await alex1.locator("#set-pane").innerText()), "Signed-in browsers shows Ana’s browsers too");
  await alex1.click('[data-act="others"]');
  await alex1.waitForSelector("#dlg .w-alert");
  await alex1.click("#dlg [data-dlg-ok]");
  await alex1.waitForFunction(() => document.getElementById("dlg").hidden, null, { timeout: 10000 });
  await alex2.evaluate(() => window.dispatchEvent(new Event("focus")));
  ok(await atPassword(alex2, "Alex", 15000) || await gateShown(alex2, "Who is signing in", 2000), "signed out elsewhere: the phone is back at sign-in");
  ok(!(await hasKeys(alex2)), "and its key and copy are gone");
  for (const [pg, name] of [[ana0, "Ana’s first browser"], [ana1, "Ana’s second browser"]]) {
    await pg.evaluate(() => window.dispatchEvent(new Event("focus")));
    ok(await atPassword(pg, "Ana", 15000), "signed out elsewhere: " + name + " is back at sign-in");
    ok(!(await hasKeys(pg)), "and its key is gone");
  }
  await ana0.context().close();
  await signIn(alex2, ALEX, HP1);
  ok(await appShown(alex2, 20000), "a new device signs in with the password, and is unlocked in the same step");
  await signIn(ana1, ANA, HP1);
  ok(await appShown(ana1, 20000), "and so does Ana");

  // Sign out
  const ana2 = await device();
  await signIn(ana2, ANA, HP1);
  ok(await appShown(ana2, 20000), "Ana signs in on another browser");
  // The app's own Sign out (the person menu, or Settings on your own page)
  await ana2.evaluate(() => import("/js/shell.js").then((m) => m.signOut()));
  await ana2.waitForURL(base + "/", { timeout: 10000 });
  await ana2.waitForTimeout(500);
  ok(!(await hasKeys(ana2)), "signing out leaves no key on that browser");
  await ana2.context().close();

  /* ================= getting back in ================= */
  section("Getting back in");
  // Two-step on, for the household, from Settings › Sign-in & Privacy
  await alex1.goto(base + "/settings/security", { waitUntil: "networkidle" });
  await alex1.click('a[data-pane="security/two-step"]');
  await alex1.waitForSelector("#ts-secret", { state: "attached" });
  const secret = (await alex1.textContent("#ts-secret")).replace(/\s/g, "");
  await alex1.fill("#ts-code", totpAt(secret, Math.floor(Date.now() / 30000)));
  await alex1.fill("#ts-pw", HP1);
  await alex1.click("#dlg [data-dlg-ok]");
  await alex1.waitForSelector("#rc-codes", { timeout: 15000 });
  await alex1.click("#dlg [data-dlg-ok]");
  ok((await inPage(alex1, async ({ core }) => core.GET("/auth/status"))).two_step === true, "two-step is on for the household (turned on with the password, encrypted)");
  const off = signinOff();
  ok(off.code === 1 && /encrypted/.test(off.text), "api.signin_off is refused while encrypted (" + off.text.trim() + ")");
  const afterOff = await (await fetch(base + "/api/auth/status")).json();
  ok(afterOff.signin && afterOff.vault.on && !!afterOff.kit && afterOff.two_step, "and nothing changed");

  // Alex forgot the password and has no phone: the Emergency Kit, without a code
  const alex3 = await device();
  await signIn(alex3, ALEX, HP1);
  await alex3.waitForSelector(".w-sicode", { timeout: 15000 });
  ok(await alex3.locator("#si-nophone").count() === 1, "the code step has “No phone or codes?”");
  await alex3.click("#si-nophone");
  ok(await gateShown(alex3, "Use your recovery key"), "No phone or codes? asks for the recovery key");
  ok((await alex3.locator("#gate").innerText()).includes("It works without two-step sign-in too."), "and says it works without two-step");
  await shot(alex3, "recover");
  await alex3.click("#vg-help");
  ok(await gateShown(alex3, "Ask Ana"), "“Lost it too?” says to ask Ana, who uses the same password");
  const lost = await alex3.locator("#gate").innerText();
  ok(lost.includes("Not even whoever runs the server can.") && !lost.includes("signin_off"), "encrypted: nobody else can open it, and no command for the server");
  await alex3.click("#vg-rec");
  ok(await gateShown(alex3, "Use your recovery key"), "and back to the recovery key");
  await alex3.fill("#vg-in", "AAAA-AAAA");
  await alex3.click("#vg-go");
  await alex3.waitForFunction(() => document.getElementById("vg-msg").textContent.trim().length > 0, null, { timeout: 15000 });
  ok((await alex3.locator("#vg-msg").innerText()).length > 0, "a mistyped key is refused");
  await alex3.fill("#vg-in", alexKey.toLowerCase());
  await alex3.click("#vg-go");
  ok(await gateShown(alex3, "Choose a new household password"), "the key from the kit works, with two-step on and no code");
  await shot(alex3, "recover-newpw");
  await alex3.fill("#vg-in", HP2);
  await alex3.fill("#vg-in2", HP2);
  await alex3.click("#vg-go");
  ok(await appShown(alex3, 30000), "a new household password, and Wally opens, without a code");
  const st3 = await inPage(alex3, async ({ core }) => core.GET("/auth/status"));
  ok(st3.me && st3.me.id === ALEX && st3.two_step === true, "signed in as Alex; two-step stays on for everyone’s phones");
  const sess = await inPage(alex3, async ({ core }) => core.GET("/sessions"));
  ok(sess.length === 1 && sess[0].current, "every other browser is signed out on the server");
  await alex1.evaluate(() => window.dispatchEvent(new Event("focus")));
  ok(await atPassword(alex1, "Alex", 15000), "Alex’s other browser is back at sign-in");
  await ana1.evaluate(() => window.dispatchEvent(new Event("focus")));
  ok(await atPassword(ana1, "Ana", 15000), "and so is Ana’s");
  // Forgot password from the password step: the same recovery key
  await alex1.click("#si-forgot");
  ok(await gateShown(alex1, "Use your recovery key"), "Forgot password asks for the recovery key too");
  await alex1.click("#vg-back");
  ok(await atPassword(alex1, "Alex"), "Back to sign in");
  // Ana: the new password, and a code from the same secret
  await signIn(ana1, ANA, HP2);
  await ana1.waitForSelector(".w-sicode", { timeout: 15000 });
  await ana1.locator("#si-in").pressSequentially(await freshCode(secret));
  ok(await appShown(ana1, 20000), "Ana signs in with the new password and the household’s two-step code, unlocked");
  ok(JSON.stringify(await ledger(ana1)) === JSON.stringify(await ledger(alex3)), "with the same ledger");

  // Settings › Sign-in, encrypted: what to do if the password is forgotten
  await alex3.goto(base + "/settings/security", { waitUntil: "networkidle" });
  await alex3.waitForSelector('a[data-pane="security/kit"]');
  const mainText = await alex3.locator("#set-pane").innerText();
  ok(mainText.includes("If you forget the password") && mainText.includes("Emergency Kit"), "Settings › Sign-in & Privacy: the Emergency Kit gets you back in");
  await alex3.click('a[data-pane="security/kit"]');
  await alex3.waitForSelector('[data-act="kit"]');
  const forgotText = await alex3.locator("#set-pane").innerText();
  ok(!forgotText.includes("password.txt") && !forgotText.includes("signin_off") && forgotText.includes("Without the kit and the password, nobody can open the data, not even whoever runs the server."), "Emergency Kit, encrypted: lost the kit too means nobody can open it; no password.txt, no command");
  // A new kit, made in this browser: the old one stops working
  const proveIn = async (key) => { const d = await device(); await d.goto(base + "/", { waitUntil: "networkidle" });
    const r = await d.evaluate(async ([k, id]) => { const s = await import("/js/vault/session.js"); s.bind(await import("/js/core.js")); try { await s.prove(id, k); return 200; } catch (e) { return e.status; } }, [key, ALEX]);
    await d.context().close(); return r; };
  await alex3.click('[data-act="kit"]');
  await alex3.fill("#kit-pw", HP2);
  await alex3.click("#dlg [data-dlg-ok]");
  await alex3.waitForSelector("#kit-saved", { timeout: 20000 });
  const newKey = (await alex3.locator("#dlg .w-kitkey").innerText()).replace(/\s+/g, "-");
  await alex3.click("#kit-saved");
  await alex3.click("#dlg [data-dlg-ok]");
  await alex3.waitForFunction(() => document.getElementById("dlg").hidden, null, { timeout: 10000 });
  ok(newKey !== alexKey && await proveIn(alexKey) === 401, "encrypted: a new kit, and the old one stops working");
  ok(await proveIn(newKey) === 200, "the new kit opens the household key");
  await alex3.goto(base + "/settings/security/two-step", { waitUntil: "networkidle" });
  await alex3.waitForSelector("#ts-toggle");
  await alex3.click("#ts-toggle");
  await alex3.fill("#ts-pw", HP2);
  await alex3.click("#dlg [data-dlg-ok]");
  await alex3.waitForFunction(() => document.getElementById("dlg").hidden, null, { timeout: 15000 });
  ok((await inPage(alex3, async ({ core }) => core.GET("/auth/status"))).two_step === false, "two-step off again, with the new password");
  await alex3.reload({ waitUntil: "networkidle" });
  await appShown(alex3);
  ok(!(await alex3.locator("#banner").textContent()).includes("helped"), "no “helped back in” banner");
  noErrors(alex3, "getting back in"); noErrors(ana1, "Ana back in");

  /* ================= the server stays in the dark ================= */
  section("What the server holds");
  await syncPage(alex3);
  const now = check("open", DB, HP2);
  const fromApp = await inPage(alex3, async ({ store }) => [...store.vault.recs.values()].filter((r) => r.obj).length);
  ok(now.items.length === fromApp, "the server has exactly the records the app has (" + fromApp + ")");
  const hits2 = Object.entries(check("grep", DB, ...WORDS, "Psychologist", "Gym", "market")).filter(([, v]) => v).map(([k]) => k);
  ok(hits2.length === 0, "still nothing readable in wally.db" + (hits2.length ? " — found " + hits2.join(", ") : ""));
  await alex3.goto(base + "/settings/security", { waitUntil: "networkidle" });
  const priv = await alex3.locator("#set-pane").innerText();
  ok(priv.includes("Not one amount, name or category") && !priv.includes("What the server can see"), "Privacy says what the server sees, without a page of its own");
  await shot(alex3, "privacy-on");

  /* ================= stopping half-way ================= */
  section("Turning off, stopped half-way");
  // The browser goes away mid-way: after the pause, nothing changed
  const quitter = await device();
  await signIn(quitter, ALEX, HP2);
  await appShown(quitter, 20000);
  await quitter.waitForTimeout(1500);
  let encrypted = digest();
  await quitter.route("**/api/vault/convert/plain", () => {}); // never answered: as if the tab closed
  await quitter.goto(base + "/settings/privacy", { waitUntil: "networkidle" });
  await quitter.click("#pv-toggle");
  await quitter.fill("#pv-pw", HP2);
  await quitter.click("[data-dlg-ok]");
  await ana1.waitForTimeout(1500);
  const paused = await (await fetch(base + "/api/auth/status")).json();
  ok(paused.vault.paused && paused.vault.paused.by === "Alex", "while it runs, everyone sees “Alex is turning off encryption”");
  const blocked = await inPage(ana1, async ({ core }) => { try { await core.POST("/transactions", { name: "x", category: "Food", amount: "1", date: new Date().toISOString().slice(0, 10) }); return "saved"; } catch (e) { return e.status + " " + e.message; } });
  ok(/^503 Alex is turning off/.test(blocked) || /^503 Alex is decrypting/.test(blocked), "and saving waits: " + blocked);
  await quitter.context().close();
  await new Promise((r) => setTimeout(r, (PAUSE + 2) * 1000));
  await fetch(base + "/api/auth/status");
  ok(digest() === encrypted && !existsSync(join(data, "wally.next.db")) && !existsSync(join(data, "conversion.json")), "the browser closed: after the pause the half-built file is gone and wally.db is untouched");
  ok(!(await (await fetch(base + "/api/auth/status")).json()).vault.paused, "and the household is writable again");

  // The server stops mid-way: on restart, nothing changed
  const victim = await device();
  await signIn(victim, ALEX, HP2);
  await appShown(victim, 20000);
  await victim.waitForTimeout(1500);
  encrypted = digest();
  let killed = null;
  await victim.route("**/api/vault/convert/readback*", async (route) => { killed = stopServer("SIGKILL"); await killed; route.abort(); });
  await victim.goto(base + "/settings/privacy", { waitUntil: "networkidle" });
  await victim.click("#pv-toggle");
  await victim.fill("#pv-pw", HP2);
  await victim.click("[data-dlg-ok]");
  await victim.waitForFunction(() => !document.getElementById("dlg-err").hidden, null, { timeout: 30000 }).catch(() => {});
  ok(killed !== null && existsSync(join(data, "wally.next.db")), "the server was killed with the new file half-built");
  await startServer();
  ok(digest() === encrypted && !existsSync(join(data, "wally.next.db")) && !existsSync(join(data, "conversion.json")), "after a restart, wally.db is the old file, untouched");
  await victim.context().close();

  /* ================= turning off ================= */
  section("Turning encryption off");
  const beforeOff = check("open", DB, HP2).items;
  await alex3.reload({ waitUntil: "networkidle" });
  await appShown(alex3);
  const encLedger = await ledger(alex3);
  // A category encrypted before colors followed spending still has its color: turning off doesn't stop on it
  const aged = await inPage(alex3, async ({ store }) => {
    const [id, r] = [...store.vault.recs.entries()].find(([, x]) => x.obj && x.obj.k === "cat");
    await store.commit(new Map([[id, { obj: Object.assign({}, r.obj, { color: 3 }) }]]));
    return r.obj.name;
  });
  ok(!!aged, "a category encrypted with an old color: " + aged);
  await alex3.goto(base + "/settings/privacy", { waitUntil: "networkidle" });
  await alex3.click("#pv-toggle");
  await shot(alex3, "turn-off");
  ok(await alex3.locator("#dlg #pv-word").count() === 0 && !(await alex3.isDisabled("#dlg [data-dlg-ok]")), "turning off asks for the password only; Turn Off stays lit");
  await alex3.click("#dlg [data-dlg-ok]");
  await alex3.waitForFunction(() => { const e = document.getElementById("dlg-err"); return e && !e.hidden && /password/i.test(e.textContent); }, null, { timeout: 5000 });
  ok(await alex3.locator("#dlg #pv-pw").count() === 1, "with the field empty it asks for the password, and stays open");
  await alex3.fill("#pv-pw", HP2);
  await alex3.click("[data-dlg-ok]");
  // The encrypted kit went with the encryption, and without it none is needed: Wally just opens again
  ok(await appShown(alex3, 60000) && await until(alex3, async ({ core }) => core.state.status && core.state.status.vault && !core.state.status.vault.on, null, 30000), "Wally opens again, in the clear");
  ok(await alex3.locator("#onb-saved").count() === 0, "turned off: no kit to save, optional without encryption");
  ok((await alex3.evaluate(() => fetch("/api/auth/status").then((r) => r.json()))).kit === null && await plainRecover(ALEX, newKey) === 401, "no kit left, and the encrypted one doesn’t work");
  const plainAfter = check("plain", DB).items;
  ok(JSON.stringify(plainAfter) === JSON.stringify(beforeOff), "every item in the clear is exactly what was encrypted (" + plainAfter.length + ")");
  ok(JSON.stringify(await ledger(alex3)) === JSON.stringify(encLedger), "the app shows the same ledger, now from the server");
  ok(!(await hasKeys(alex3)), "no key is left on the browser");
  await ana1.evaluate(() => window.dispatchEvent(new Event("focus")));
  await ana1.waitForTimeout(3000);
  ok(await appShown(ana1) && !(await hasKeys(ana1)), "Ana’s open browser notices, forgets its copy and reloads in the clear");
  ok(JSON.stringify(await ledger(ana1)) === JSON.stringify(encLedger), "and shows the same ledger");
  noErrors(alex3, "turning off"); noErrors(ana1, "Ana after turning off");

  /* ================= and on again, with more data ================= */
  section("A bigger household");
  const big = apiClient();
  const kdf = await big("GET", "/vault/kdf");
  const rows = [];
  for (let i = 0; i < 5000; i++) rows.push({ name: "Shop " + (i % 97), category: ["Groceries", "Food", "Shopping", "Travel"][i % 4], amount: String(5 + (i % 50) + 0.25), date: daysAgo(i % 700), person_id: i % 3 ? ALEX : null });
  const login = await alex3.evaluate(async ([pw, salt]) => (await (await import("/js/vault/crypto.js")).fromPassword(pw, salt)).login, [HP2, kdf.salt]);
  await big("POST", "/login", { person_id: ALEX, password: login });
  await big("POST", "/transactions/import", rows);
  await alex3.reload({ waitUntil: "networkidle" });
  await appShown(alex3, 30000);
  const plainBig = check("plain", DB).items;
  await alex3.goto(base + "/settings/privacy", { waitUntil: "networkidle" });
  await alex3.click("#pv-toggle");
  await alex3.fill("#pv-pw", HP2);
  const t0 = Date.now();
  await alex3.click("[data-dlg-ok]");
  await alex3.waitForSelector("#dlg .w-kitkey", { timeout: 120000 });
  const took = (Date.now() - t0) / 1000;
  ok(took < 60, "turning on with " + plainBig.length + " items took " + took.toFixed(1) + " s");
  await alex3.click("#kit-saved");
  await alex3.click("#dlg [data-dlg-ok]");
  await appShown(alex3, 30000);
  const t1b = Date.now();
  await alex3.reload({ waitUntil: "networkidle" });
  await appShown(alex3, 30000);
  ok((Date.now() - t1b) / 1000 < 8, "opening Wally with " + plainBig.length + " encrypted records takes " + ((Date.now() - t1b) / 1000).toFixed(1) + " s");
  ok(JSON.stringify(check("open", DB, HP2).items) === JSON.stringify(plainBig), "every one of them opens to exactly what it was");
  const t2 = Date.now();
  await inPage(alex3, async ({ core }) => core.PUT("/categories/Travel", { name: "Trips" }));
  ok((Date.now() - t2) / 1000 < 10, "renaming a category on 1,250 transactions takes " + ((Date.now() - t2) / 1000).toFixed(1) + " s");
  noErrors(alex3, "a bigger household");
} catch (e) {
  failures++;
  console.log("  FAIL " + (e.stack || e));
} finally {
  await browser.close();
  await stopServer();
}
console.log(failures ? "\n" + failures + " failed" : "\nAll encryption checks passed.");
process.exit(failures ? 1 : 0);