Ctrl K
ComunidadGuías…

tests/e2e/signin.mjs

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

Archivo original ↗Preguntar a KiBot
/* Sign-in and the full-screen steps before the app, on real servers.
   Starts three servers of its own: the demo household, an empty database, and a copy of a v1 database.
     REPO=/path/to/Wally PYTHON=/path/to/python PORT=8806 node signin.mjs
   Screenshots go to SHOTS (a folder) when it is set. */
import { chromium } from "playwright";
import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHmac, createHash, randomBytes } from "node:crypto";

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

let failures = 0;
const ok = (cond, msg) => { if (cond) console.log("  ok  " + msg); else { failures++; console.log("  FAIL " + msg); } };
const servers = [];

async function startServer(port, opts) {
  const data = mkdtempSync(join(tmpdir(), "wally-signin-"));
  if (opts.copy) copyFileSync(opts.copy, join(data, "wally.db"));
  const env = { ...process.env, WALLY_DATA: data };
  if (opts.demo) env.DEMO = "true"; else delete env.DEMO;
  const proc = spawn(PYTHON, ["-m", "uvicorn", "utils.dev_server:app", "--port", String(port)], { cwd: REPO, env, stdio: "ignore" });
  servers.push(proc);
  const base = "http://localhost:" + port;
  for (let i = 0; i < 80; i++) {
    try { const r = await fetch(base + "/api/auth/status"); if (r.ok) return { base, data }; } catch (e) { /* not yet */ }
    await new Promise((r) => setTimeout(r, 250));
  }
  throw new Error("server on " + port + " did not start");
}

/* RFC 6238, as the server does it */
function base32(s) {
  const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  let bits = "";
  for (const ch of s.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 totp(secret, step = 0) {
  const counter = Math.floor(Date.now() / 30000) + step, 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");
}
/* Emergency Kit keys, as Wally makes them: 29 random bytes and a check byte, in Crockford base32 (48 characters).
   A body of 31 bytes makes the 52-character key of kits from before 2.0 shipped, which still work. */
const ABC = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const SIZES = { 48: 29, 52: 31 };
function keyFrom(body) {
  const chars = body.length === 31 ? 52 : 48;
  const raw = Buffer.concat([body, createHash("sha256").update(body).digest().subarray(0, 1)]);
  const bits = [...raw].map((b) => b.toString(2).padStart(8, "0")).join("").padEnd(chars * 5, "0");
  let out = "";
  for (let i = 0; i < chars; i++) out += ABC[parseInt(bits.slice(i * 5, i * 5 + 5), 2)];
  return out.match(/.{4}/g).join("-");
}
function checks(key) {
  const s = key.replace(/-/g, ""), n = SIZES[s.length];
  if (!n) return false;
  const bits = [...s].map((c) => ABC.indexOf(c).toString(2).padStart(5, "0")).join("");
  const raw = Buffer.from(Array.from({ length: n + 1 }, (_, i) => parseInt(bits.slice(i * 8, i * 8 + 8), 2)));
  return !bits.slice((n + 1) * 8).includes("1") && raw[n] === createHash("sha256").update(raw.subarray(0, n)).digest()[0];
}
const otherKey = () => keyFrom(randomBytes(29));
/* The key with one character changed, so that its check byte no longer matches */
function typo(key) {
  for (let i = 0; i < key.length; i++) {
    if (key[i] === "-") continue;
    const k = key.slice(0, i) + (key[i] === "A" ? "B" : "A") + key.slice(i + 1);
    if (!checks(k)) return k;
  }
  throw new Error("no typo found");
}
/* What whoever runs the server types: python3 -m api.signin_off, on that server's data */
function signinOff(dataDir) {
  const r = spawnSync(PYTHON, ["-m", "api.signin_off"], { cwd: REPO, env: { ...process.env, WALLY_DATA: dataDir }, encoding: "utf8" });
  return { code: r.status, text: (r.stdout || "") + (r.stderr || "") };
}

/* The server takes each 30-second code once, for the whole household (everyone shares the secret).
   The next code it will take: this period's or the next one's, or wait for a new period. */
let lastCounter = -1;
async function freshCode(secret) {
  for (;;) {
    const now = Math.floor(Date.now() / 30000);
    for (const step of [0, 1]) if (now + step > lastCounter) { lastCounter = now + step; return totp(secret, step); }
    await new Promise((r) => setTimeout(r, 1000));
  }
}

const browser = await chromium.launch();
async function open(base, { width = 1440, height = 900, person, theme = "light" } = {}) {
  const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, serviceWorkers: "block" });
  if (person) await ctx.addInitScript((p) => localStorage.setItem("wally.person", p), person);
  const page = await ctx.newPage();
  page.errors = [];
  page.on("console", (m) => { if (m.type() === "error") page.errors.push(m.text()); });
  page.on("pageerror", (e) => page.errors.push("PAGEERROR " + e.message));
  await page.goto(base + "/", { waitUntil: "networkidle" });
  return page;
}
const shot = async (page, name) => { if (SHOTS) await page.waitForTimeout(350), await page.screenshot({ path: join(SHOTS, name + ".png") }); };
const gateText = (page) => page.locator("#gate").innerText();
const appShown = (page) => page.waitForSelector("#app:not([hidden])", { timeout: 8000 }).then(() => true, () => false);
/* Sign out: the app's own Sign out (in the person menu, and in Settings on your own page) */
const signOutVia = async (page) => { await page.goto(new URL("/settings", page.url()).href, { waitUntil: "networkidle" }); await page.evaluate(() => import("/js/shell.js").then((m) => m.signOut())); };
/* Wrong passwords and codes are answered 400, 401 or 429 on purpose here; the browser logs those
   responses. Anything else in the console is a failure. */
const EXPECTED = /^Failed to load resource: the server responded with a status of (400|401|429)/;
const noErrors = (page, what) => {
  const errs = page.errors.filter((e) => !EXPECTED.test(e));
  ok(errs.length === 0, what + ": no console errors" + (errs.length ? " — " + errs.join(" | ") : ""));
};

try {
  /* ================= the demo household ================= */
  const demo = await startServer(PORT, { demo: true });
  console.log("Demo household: " + demo.base);

  // Who is using this browser (sign-in off)
  {
    const page = await open(demo.base);
    await page.waitForSelector("#gate .w-whop");
    ok((await gateText(page)).includes("Who’s using Wally?"), "who: asks who is using Wally");
    ok(await page.locator("#gate .w-whop").count() === 2, "who: lists both people");
    ok(!(await gateText(page)).includes("Wally remembers") && !(await gateText(page)).includes("No password needed"), "who: the title and the faces only, like the sign-in picker");
    await page.waitForTimeout(700);
    await shot(page, "who");
    await page.keyboard.press("Tab");
    await page.keyboard.press("ArrowRight");
    ok(await page.evaluate(() => document.activeElement.dataset.prof) === "2", "who: arrows move between people");
    await page.keyboard.press("Enter");
    ok(await appShown(page), "who: picking a person opens the app");
    ok(await page.evaluate(() => localStorage.getItem("wally.person")) === "2", "who: remembered on this browser");
    noErrors(page, "who");
    await page.context().close();
  }

  // Turn sign-in on through the API: one household password for everyone. No Emergency Kit comes with
  // it (optional without encryption): one is made next, with the password, as Settings would
  let kit;
  {
    const r = await fetch(demo.base + "/api/signin", { method: "PUT", headers: { "Content-Type": "application/json", "X-Wally-Person": "1" }, body: JSON.stringify({ enabled: true, password: "household1" }) });
    const body = await r.json();
    ok(r.ok && body.signin === true && !("kit" in body), "API: sign-in turned on, with the household password, and no kit");
    ok((await (await fetch(demo.base + "/api/auth/status")).json()).kit === null, "API: no Emergency Kit yet");
    const cookie = (r.headers.get("set-cookie") || "").split(";")[0];
    const post = (b) => fetch(demo.base + "/api/kit", { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify(b) });
    ok((await post({})).status === 400, "API: a kit asks for the password");
    kit = (await (await post({ current: "household1" })).json()).key;
    ok(/^([0-9A-Z]{4}-){11}[0-9A-Z]{4}$/.test(kit || "") && checks(kit), "API: the household's Emergency Kit, made by the server, in 12 groups (" + kit + ")");
    const st = await (await fetch(demo.base + "/api/auth/status")).json();
    ok(st.signin && st.two_step === false && !("require_two_step" in st) && !("needs_two_step" in st) && !("household_password" in st) && !("must_change_password" in st), "API: status says two_step, and nothing per person");
    ok(!st.signed_in && typeof st.kit === "string" && !isNaN(Date.parse(st.kit)), "API: status says when the kit was made, even signed out (" + st.kit + ")");
    const kdf = await (await fetch(demo.base + "/api/vault/kdf")).json();
    ok(!("one_time" in kdf), "API: /vault/kdf has no one_time");
    const gone = (await fetch(demo.base + "/api/login/recover", { method: "POST" })).status;
    ok(gone === 404 || gone === 405, "API: /login/recover is gone (" + gone + ")");
    ok(st.people.every((p) => !("has_password" in p) && !("two_step" in p)), "API: people carry no password or two-step of their own");
  }

  // Wrong passwords, not counted out loud, the wait after ten, then the right one
  const alex = await open(demo.base);
  {
    const page = alex;
    await page.waitForSelector("#gate .w-prof");
    ok((await gateText(page)).includes("Who is signing in?"), "signin: who is signing in");
    ok(!(await gateText(page)).includes("Wally remembers"), "signin: just the faces, no notes");
    await shot(page, "signin-who");
    await page.click('[data-prof="1"]');
    await page.waitForSelector("#si-in");
    ok(await page.locator("#gate-h").innerText() === "Alex" && await page.getAttribute("#si-in", "placeholder") === "Household password", "signin: the household password, for the person picked");
    ok(await page.evaluate(() => document.activeElement.id) === "si-in", "signin: focus in the password field");
    ok(await page.getAttribute("#si-in", "autocomplete") === "current-password", "signin: password manager friendly");
    await page.click("#si-not");
    ok(await page.locator("#gate .w-prof").count() === 2, "signin: Switch user goes back to the names");
    await page.click('[data-prof="1"]');
    await page.fill("#si-in", "wrong-one");
    await page.click("[data-eye]");
    ok(await page.getAttribute("#si-in", "type") === "text", "signin: show password");
    await page.press("#si-in", "Enter");
    await page.waitForSelector(".w-lockfield.err");
    ok((await page.textContent("#si-msg")).trim() === "That password isn’t right.", "signin: a wrong password says only that, with no tries counted");
    ok(await page.getAttribute("#si-in", "aria-invalid") === "true", "signin: field marked invalid");
    await shot(page, "signin-wrong");
    for (let n = 2; n <= 9; n++) {
      await page.fill("#si-in", "wrong-" + n);
      await Promise.all([page.waitForResponse((r) => r.url().endsWith("/api/login") && r.status() === 401), page.press("#si-in", "Enter")]);
    }
    ok(!/Wait/.test(await page.textContent("#si-msg")), "signin: nine wrong tries, still no wait");
    await page.fill("#si-in", "wrong-10");
    await page.press("#si-in", "Enter");
    await page.waitForFunction(() => /Wait \d+ seconds/.test(document.getElementById("si-msg").textContent));
    const first = await page.textContent("#si-msg");
    ok(await page.isDisabled("#si-go"), "signin: tenth wrong try: Sign in waits");
    await page.waitForTimeout(2100);
    const later = await page.textContent("#si-msg");
    ok(+first.match(/(\d+) seconds/)[1] > +later.match(/(\d+) seconds?/)[1], "signin: the wait counts down live (" + first.match(/\d+/)[0] + " → " + later.match(/\d+/)[0] + ")");
    await shot(page, "signin-wait");
    // An eleventh try from a fresh page is refused by the server (429) with its own wait
    await page.reload({ waitUntil: "networkidle" });
    await page.waitForSelector("#gate .w-prof");
    await page.click('[data-prof="1"]');
    await page.fill("#si-in", "wrong-6");
    await page.press("#si-in", "Enter");
    await page.waitForFunction(() => /Wait \d+ seconds/.test(document.getElementById("si-msg").textContent));
    ok(true, "signin: the server's 429 shows the wait");
    const secs = +(await page.textContent("#si-msg")).match(/(\d+) seconds/)[1];
    await page.waitForFunction(() => !document.getElementById("si-go").disabled, null, { timeout: (secs + 3) * 1000 });
    ok((await page.textContent("#si-msg")).trim() === "", "signin: the message goes when the wait is over");
    await page.fill("#si-in", "household1");
    await page.press("#si-in", "Enter");
    ok(await appShown(page), "signin: the household password opens the app for Alex");
    ok(await page.evaluate(() => localStorage.getItem("wally.lastSignin")) === "1", "signin: remembers Alex on this browser");
    const st = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(st.me && st.me.id === 1 && !(await page.locator("#gate").isVisible()), "signin: signed in as Alex, nothing more to do");
    noErrors(page, "signin");
  }

  // Ana, on her phone: the same password, under her own name
  const ana = await open(demo.base, { width: 390, height: 844 });
  {
    const page = ana;
    await page.waitForSelector("#gate .w-prof");
    await page.click('[data-prof="2"]');
    await page.waitForSelector("#si-in");
    ok(await page.locator("#gate-h").innerText() === "Ana", "same password: Ana picks her name");
    await shot(page, "signin-password-phone");
    await page.fill("#si-in", "household1");
    await page.press("#si-in", "Enter");
    ok(await appShown(page), "same password: the household password opens the app for Ana too");
    const st = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(st.me && st.me.id === 2, "same password: signed in as Ana, not asked to choose her own");
    noErrors(page, "Ana signs in");
  }

  // Signed-in browsers: who is on each
  {
    const page = alex;
    const sessions = await page.evaluate(() => fetch("/api/sessions").then((r) => r.json()));
    // (the API call that turned sign-in on is a third one, as Alex)
    ok(sessions.length === 3 && sessions.every((s) => s.person_id === 1 || s.person_id === 2) && sessions.some((s) => s.person_id === 1 && s.current) && sessions.some((s) => s.person_id === 2 && !s.current), "sessions: every browser listed, each with who is on it");
    await page.goto(demo.base + "/settings/security/browsers", { waitUntil: "networkidle" });
    await page.waitForFunction(() => /Last seen/.test(document.getElementById("set-pane").textContent));
    const pane = await page.textContent("#set-pane");
    ok(/Ana · Last seen/.test(pane) && /Alex · Last seen/.test(pane), "Settings › Sign-in & Privacy › Signed-in browsers shows who is on each");
    await page.goto(demo.base + "/settings/security", { waitUntil: "networkidle" });
    await page.waitForSelector("#set-pane .w-sec");
    const main = await page.textContent("#set-pane");
    ok(main.includes("Change household password") && main.includes("Two-step sign-in") && !main.includes("Recovery codes"), "Settings › Sign-in & Privacy: the household password and two-step rows, no recovery codes while two-step is off");
    await shot(page, "settings-signin-browsers");
  }

  // Two-step for the household: Alex turns it on (with the household password), through the API
  let secret, codes;
  {
    const page = alex;
    const setup = await (await page.request.post(demo.base + "/api/two-step/setup")).json();
    secret = setup.secret;
    ok(/^[A-Z2-7]{32}$/.test(secret) && setup.uri.startsWith("otpauth://totp/"), "two-step: a secret to scan");
    const bad = await page.request.post(demo.base + "/api/two-step/confirm", { data: { code: totp(secret), password: "not-the-one" }, failOnStatusCode: false });
    ok(bad.status() === 401, "two-step: turning it on with a wrong password is refused (" + bad.status() + ")");
    const wrongCode = await page.request.post(demo.base + "/api/two-step/confirm", { data: { code: "000000", password: "household1" }, failOnStatusCode: false });
    ok(wrongCode.status() === 400, "two-step: a wrong code is refused");
    const r = await page.request.post(demo.base + "/api/two-step/confirm", { data: { code: totp(secret), password: "household1" } });
    codes = (await r.json()).recovery_codes;
    ok(codes.length === 10, "two-step: on, with ten recovery codes for the household");
    const st = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(st.two_step === true, "two-step: status says it is on");
  }

  // Alex signs out and in: the password, then the code (wrong, then right, typed digit by digit)
  {
    const page = alex;
    await signOutVia(page);
    await page.waitForSelector("#si-in");
    ok(await page.locator("#gate-h").innerText() === "Alex", "remember: after Sign out, Wally goes straight to Alex");
    await page.fill("#si-in", "household1");
    await page.press("#si-in", "Enter");
    await page.waitForSelector(".w-sicode");
    ok(await page.getAttribute("#si-in", "autocomplete") === "one-time-code" && await page.getAttribute("#si-in", "inputmode") === "numeric", "code: one-time-code and numeric keyboard");
    ok(await page.locator("#si-nophone").isVisible() && (await page.textContent("#si-nophone")).includes("No phone or codes?"), "code: a “No phone or codes?” link");
    await page.locator("#si-in").pressSequentially("111111");
    await page.waitForSelector(".w-lockfield.err");
    ok((await page.textContent("#si-msg")).includes("Codes change every 30 seconds"), "code: wrong code explained, sent without pressing Sign in");
    await shot(page, "code-wrong");
    await page.locator("#si-in").pressSequentially(await freshCode(secret));
    ok(await appShown(page), "code: the right code opens the app");
    noErrors(page, "code");
  }

  // Ana, on a second browser: the same password and a code from the same secret
  const ana2 = await open(demo.base);
  {
    const page = ana2;
    await page.waitForSelector("#gate .w-prof");
    await page.click('[data-prof="2"]');
    await page.fill("#si-in", "household1");
    await page.press("#si-in", "Enter");
    await page.waitForSelector(".w-sicode");
    ok((await gateText(page)).includes("Two-step sign-in"), "shared two-step: Ana is asked for a code too");
    await page.locator("#si-in").pressSequentially(await freshCode(secret));
    ok(await appShown(page), "shared two-step: a code from the household's secret signs Ana in");
    const st = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(st.me && st.me.id === 2, "shared two-step: as Ana");
    noErrors(page, "shared two-step");
  }

  // Add a phone: the password, then the same secret again
  {
    const page = alex;
    await page.goto(demo.base + "/settings/security/two-step", { waitUntil: "networkidle" });
    await page.waitForFunction(() => /Codes left\s*10 of 10/.test(document.getElementById("set-pane").textContent));
    ok(true, "Settings › Two-step sign-in: 10 of 10 recovery codes left");
    ok(await page.locator("#ts-toggle").count() === 1, "Settings › Two-step sign-in: two-step can be turned off");
    await page.click('[data-act="2show"]');
    await page.waitForSelector("#ts-pw");
    ok(await page.locator("#ts-secret").count() === 0, "Add a phone: asks for the password before showing anything");
    await page.fill("#ts-pw", "not-the-one");
    await page.click("#dlg [data-dlg-ok]");
    await page.waitForFunction(() => { const e = document.getElementById("dlg-err"); return e && !e.hidden && e.textContent; });
    ok(/not right/.test(await page.textContent("#dlg-err")), "Add a phone: a wrong password is refused");
    await page.fill("#ts-pw", "household1");
    await page.click("#dlg [data-dlg-ok]");
    await page.waitForSelector("#ts-secret", { state: "attached" });
    ok((await page.textContent("#ts-secret")).replace(/\s/g, "") === secret, "Add a phone: shows the same secret as everyone's phone");
    ok(await page.locator("#dlg svg.w-qr").count() === 1, "Add a phone: with its QR code");
    await shot(page, "add-a-phone");
    await page.click("#dlg [data-dlg-ok]");
    await page.waitForFunction(() => document.getElementById("dlg").hidden);
    noErrors(page, "add a phone");
  }

  // A recovery code, once
  {
    const page = alex;
    await signOutVia(page);
    await page.waitForSelector("#si-in");
    await page.fill("#si-in", "household1");
    await page.press("#si-in", "Enter");
    await page.waitForSelector(".w-sicode");
    await page.click("#si-alt");
    ok((await gateText(page)).includes("Enter one of your recovery codes"), "recovery: asks for a recovery code");
    await page.fill("#si-in", codes[0]);
    await page.press("#si-in", "Enter");
    ok(await appShown(page), "recovery: a recovery code signs in");
    await signOutVia(page);
    await page.waitForSelector("#si-in");
    await page.fill("#si-in", "household1");
    await page.press("#si-in", "Enter");
    await page.waitForSelector(".w-sicode");
    await page.click("#si-alt");
    await page.fill("#si-in", codes[0]);
    await page.press("#si-in", "Enter");
    await page.waitForSelector(".w-lockfield.err");
    ok((await page.textContent("#si-msg")).includes("already used"), "recovery: a used code is refused");
    await page.fill("#si-in", codes[1]);
    await page.press("#si-in", "Enter");
    ok(await appShown(page), "recovery: another code signs in");
    noErrors(page, "recovery");
  }

  // Alex changes the household password: Ana's browsers are signed out, and she signs in with the new one
  {
    const page = alex;
    await page.goto(demo.base + "/settings/security", { waitUntil: "networkidle" });
    await page.waitForSelector('[data-act="password"]');
    await page.click('[data-act="password"]');
    await page.fill("#pw-cur", "household1");
    await page.fill("#pw-new", "household2");
    await page.fill("#pw-rep", "household2");
    await page.click("#dlg [data-dlg-ok]");
    await page.waitForFunction(() => document.getElementById("toast").classList.contains("on") && /Password changed/.test(document.getElementById("toast-t").textContent), null, { timeout: 15000 });
    ok(true, "change password: changed in Settings › Sign-in & Privacy");
    const sessions = await page.evaluate(() => fetch("/api/sessions").then((r) => r.json()));
    ok(sessions.length === 1 && sessions[0].current, "change password: only this browser is still signed in");
    await ana.reload({ waitUntil: "networkidle" });
    await ana.waitForSelector("#si-in");
    ok(await ana.locator("#gate-h").innerText() === "Ana" && await ana.locator("#si-in").count() === 1, "change password: Ana's phone is signed out");
    await ana2.reload({ waitUntil: "networkidle" });
    ok(await ana2.locator("#si-in").count() === 1 && !(await ana2.locator("#app").isVisible()), "change password: and her other browser too");
    await ana.fill("#si-in", "household1");
    await ana.press("#si-in", "Enter");
    await ana.waitForSelector(".w-lockfield.err");
    ok(true, "change password: the old one no longer works");
    await ana.fill("#si-in", "household2");
    await ana.press("#si-in", "Enter");
    await ana.waitForSelector(".w-sicode");
    await ana.locator("#si-in").pressSequentially(await freshCode(secret));
    ok(await appShown(ana), "change password: Ana signs in with the new one (and the same two-step)");
    noErrors(page, "change password"); noErrors(ana, "Ana after the new password");
    await ana2.context().close();
  }

  // No phone or codes, and the password forgotten: the Emergency Kit, without a code, then a new household password
  {
    const page = alex;
    await signOutVia(page);
    await page.waitForSelector("#si-in");
    await page.fill("#si-in", "household2");
    await page.press("#si-in", "Enter");
    await page.waitForSelector("#si-nophone");
    await page.click("#si-nophone");
    await page.waitForSelector("textarea#vg-in");
    ok((await gateText(page)).includes("Use your recovery key") && (await gateText(page)).includes("It works without two-step sign-in too."), "no phone: asks for the recovery key, which works without two-step");
    // Lost it too: whoever runs the server can turn sign-in off
    await page.click("#vg-help");
    await page.waitForSelector("#vg-rec");
    const help = await gateText(page);
    ok(help.includes("Ask Ana") && !help.includes("signin_off"), "Lost it too?: one sentence, ask Ana; the server command is in the README");
    await shot(page, "lost-kit-too");
    await page.click("#vg-rec");
    await page.waitForSelector("textarea#vg-in");
    // A key from another kit: not this household's
    await page.fill("#vg-in", otherKey());
    await page.click("#vg-go");
    await page.waitForFunction(() => document.getElementById("vg-msg").textContent.trim().length > 0);
    ok((await page.textContent("#vg-msg")).trim() === "That recovery key isn’t right.", "kit: a wrong key is refused, with no tries counted (" + (await page.textContent("#vg-msg")) + ")");
    // One letter mistyped: caught by the check digit
    await page.fill("#vg-in", typo(kit));
    await page.click("#vg-go");
    await page.waitForFunction(() => /typo/.test(document.getElementById("vg-msg").textContent));
    ok(true, "kit: a mistyped key is caught (" + (await page.textContent("#vg-msg")) + ")");
    await shot(page, "kit-typo");
    // In lower case, without dashes: still the key
    await page.fill("#vg-in", kit.toLowerCase().replace(/-/g, ""));
    await page.click("#vg-go");
    await page.waitForSelector("#vg-in2");
    ok((await gateText(page)).includes("Choose a new household password"), "kit: the right key (lower case, no dashes), no code, then “Choose a new household password”");
    await shot(page, "choose-household-password");
    await page.fill("#vg-in", "short");
    await page.fill("#vg-in2", "short");
    await page.click("#vg-go");
    await page.waitForFunction(() => /at least 8/.test(document.getElementById("vg-msg").textContent));
    ok(true, "new password: too short is refused");
    await page.fill("#vg-in", "household3");
    await page.fill("#vg-in2", "household-typo");
    await page.click("#vg-go");
    await page.waitForFunction(() => /different/.test(document.getElementById("vg-msg").textContent));
    ok(true, "new password: different passwords are refused");
    await page.fill("#vg-in", "household3");
    await page.fill("#vg-in2", "household3");
    await page.click("#vg-go");
    ok(await appShown(page), "new password: then the app opens, without a code");
    const st = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(st.me && st.me.id === 1 && st.two_step === true && !!st.kit, "new password: signed in as Alex; two-step stays on, and so does the kit");
    const sessions = await page.evaluate(() => fetch("/api/sessions").then((r) => r.json()));
    ok(sessions.length === 1 && sessions[0].current, "new password: every other browser is signed out");
    noErrors(page, "kit");
    // Ana: signed out, and in with the new password and the same two-step
    await ana.reload({ waitUntil: "networkidle" });
    await ana.waitForSelector("#si-in");
    ok(await ana.locator("#gate-h").innerText() === "Ana" && await ana.locator("#si-in").count() === 1, "new password: Ana's phone is signed out");
    await ana.fill("#si-in", "household3");
    await ana.press("#si-in", "Enter");
    await ana.waitForSelector(".w-sicode");
    await ana.locator("#si-in").pressSequentially(await freshCode(secret));
    ok(await appShown(ana), "new password: Ana signs in with it, and the household's code");
    noErrors(ana, "Ana after the kit");
    // Forgot password on the password step: the same recovery key, and the kit still works
    const other = await open(demo.base);
    await other.waitForSelector("#gate .w-prof");
    await other.click('[data-prof="2"]');
    await other.click("#si-forgot");
    await other.waitForSelector("textarea#vg-in");
    ok((await gateText(other)).includes("Use your recovery key"), "forgot: Forgot password? asks for the recovery key too");
    await other.click("#vg-back");
    // On a browser that hasn't signed in before, Back starts sign-in over, at the names
    await other.waitForSelector("#si-forgot, #gate .w-prof");
    ok(/^Ana$|Who is signing in\?/.test(await other.locator("#gate-h").innerText()), "forgot: Back to sign in");
    noErrors(other, "forgot");
    await other.context().close();
  }

  // Whoever runs the server, when the kit is lost too: python3 -m api.signin_off
  {
    const out = signinOff(demo.data);
    ok(out.code === 0 && /Sign-in is off/.test(out.text), "signin_off: turns sign-in off (" + out.text.trim() + ")");
    const st = await (await fetch(demo.base + "/api/auth/status")).json();
    ok(st.signin === false && st.kit === null && st.two_step === false, "signin_off: no password, kit or two-step left");
    await alex.reload({ waitUntil: "networkidle" });
    await alex.waitForSelector("#gate .w-whop");
    ok((await gateText(alex)).includes("Who’s using Wally?"), "signin_off: the signed-in browser now asks who is using it");
    const again = signinOff(demo.data);
    ok(again.code === 0 && /already off/.test(again.text), "signin_off: a second time, it says it is already off");
    noErrors(alex, "signin_off");
    await ana.context().close();
    await alex.context().close();
  }

  /* ================= an empty database: first run ================= */
  const empty = await startServer(PORT + 1, {});
  console.log("Empty database: " + empty.base);
  {
    const page = await open(empty.base);
    await page.waitForSelector(".w-onb");
    ok((await gateText(page)).includes("Welcome to Wally"), "welcome: shown on an empty database");
    await shot(page, "welcome-1");
    await page.selectOption("#onb-cur", "USD");
    await page.click('#onb-pos [data-v="left"]');
    await page.click("#onb-next");
    await page.waitForSelector("[data-name]");
    ok((await gateText(page)).includes("Who shares this household?"), "welcome: step 2, people");
    await page.click("#onb-next");
    await page.waitForSelector("#onb-err:not([hidden])");
    ok((await page.textContent("#onb-err")).includes("your own name"), "welcome: a name is needed");
    await page.fill('[data-name="0"]', "Sam");
    ok((await page.locator('[data-av="0"]').innerText()).trim() === "S", "welcome: the face fills in as the name is typed");
    await page.click("#onb-add");
    ok(await page.evaluate(() => document.activeElement.dataset.name) === "1", "welcome: Add someone focuses the new row");
    await page.keyboard.type("Kim");
    await page.click("#onb-add");
    await page.keyboard.type("Extra");
    await page.click('[data-drop="2"]');
    ok(await page.locator("[data-name]").count() === 2, "welcome: a row can be removed");
    ok(await page.locator("#onb-pw").count() === 0 && await page.locator(".w-onbdots li").count() === 6, "welcome: one question a step, six dots from the start");
    await page.click("#onb-next");
    // The password, a step of its own
    await page.waitForSelector("#onb-pwd");
    ok((await gateText(page)).includes("Create a household password") && await page.locator("#onb-skip").innerText() === "Not now", "welcome: step 3, the household password, with Not now");
    await page.fill("#onb-pwd", "short");
    await page.fill("#onb-pwd2", "short");
    await page.click("#onb-next");
    await page.waitForFunction(() => document.getElementById("onb-err").textContent.includes("8"));
    ok(true, "welcome: a short password is refused");
    await page.fill("#onb-pwd", "household-pass");
    await page.fill("#onb-pwd2", "household-pazz");
    await page.click("#onb-next");
    await page.waitForFunction(() => document.getElementById("onb-err").textContent.includes("match"));
    ok(true, "welcome: two passwords that don’t match are refused");
    await page.fill("#onb-pwd2", "household-pass");
    await shot(page, "welcome-2");
    await page.click("#onb-next");
    // Then encrypting, unless it waits (tests/e2e/vault-setup.mjs covers encrypting)
    await page.waitForFunction(() => document.getElementById("gate").innerText.includes("Encrypt your data"));
    ok(await page.locator("#onb-next").innerText() === "Encrypt" && await page.locator(".w-onbfoot #onb-skip").innerText() === "Not now", "welcome: step 4, encrypting, with Not now under it");
    // Back keeps the password typed, and nothing was saved yet
    await page.click("#onb-back");
    await page.waitForSelector("#onb-pwd");
    ok(await page.inputValue("#onb-pwd") === "household-pass" && !(await (await fetch(empty.base + "/api/auth/status")).json()).signin, "welcome: Back to the password keeps it, and sign-in isn't on yet");
    await page.click("#onb-next");
    await page.waitForSelector("#onb-skip");
    await page.click("#onb-skip");
    // Not encrypted: no Emergency Kit (optional without encryption), straight to the data
    await page.waitForSelector("[data-onb-data]");
    ok((await gateText(page)).includes("Bring your data") && await page.locator(".w-kitkey").count() === 0, "welcome: not now: no kit, straight to your data");
    ok(await page.locator(".w-onbdots li").count() === 5, "welcome: five dots now, the kit step gone with encrypting");
    await shot(page, "welcome-3");
    await page.click('[data-onb-data="import"]');
    ok((await page.textContent("#onb-next")).includes("Choose a file"), "welcome: Import a CSV says Choose a file");
    await page.click("#onb-next");
    await page.waitForURL("**/settings/import");
    ok(await appShown(page), "welcome: Import opens the app at Settings › Import");
    const st = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(st.onboarded && st.people.length === 2 && st.signin && st.me && st.me.name === "Sam" && st.kit === null, "welcome: onboarded, two people, sign-in on with no kit, signed in as Sam");
    const cur = await page.evaluate(() => fetch("/api/currency").then((r) => r.json()));
    ok(cur.selected === "USD" && cur.position === "left", "welcome: currency and symbol position saved");
    noErrors(page, "welcome");
    // Kim, on another browser: the same password, under her own name
    const kim = await open(empty.base);
    await kim.waitForSelector("#gate .w-prof");
    await kim.click('#gate .w-prof:has-text("Kim")');
    await kim.fill("#si-in", "household-pass");
    await kim.press("#si-in", "Enter");
    ok(await appShown(kim), "welcome: Kim signs in with the household password chosen at setup");
    const kst = await kim.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(kst.me && kst.me.name === "Kim" && !(await kim.locator("#gate").isVisible()), "welcome: as Kim, with nothing more to choose");
    noErrors(kim, "welcome, Kim");
    await kim.context().close();
    await page.context().close();
  }

  /* ================= upgrade from v1: the household password, then people ================= */
  const v1 = await startServer(PORT + 2, { copy: join(REPO, "tests/fixtures/v1.db") });
  console.log("v1 database: " + v1.base);
  {
    const page = await open(v1.base);
    await page.waitForSelector("#si-in");
    ok((await gateText(page)).includes("Enter the household password"), "upgrade: the household password, nobody to pick");
    await shot(page, "upgrade-household");
    await page.fill("#si-in", "nope-nope");
    await page.press("#si-in", "Enter");
    await page.waitForSelector(".w-lockfield.err");
    ok(true, "upgrade: a wrong household password is refused");
    // Forgot it: Wally 1 had no Emergency Kit, so only whoever runs the server can help
    ok((await (await fetch(v1.base + "/api/auth/status")).json()).kit === null, "upgrade: no kit yet");
    await page.click("#si-forgot");
    await page.waitForSelector("#si-back");
    ok((await gateText(page)).includes("There’s no Emergency Kit") && !(await gateText(page)).includes("Wally 1") && (await page.textContent(".w-cmd code")) === "docker exec wally python3 -m api.signin_off" && await page.locator("#cmd-copy").count() === 1,
      "upgrade: forgot says there's no kit, with the command that turns sign-in off, to copy");
    await shot(page, "upgrade-forgot");
    await page.click("#si-back");
    await page.waitForSelector("#si-forgot");
    ok((await gateText(page)).includes("Enter the household password"), "upgrade: back on the household password");
    // Remembered after all: the 1.x household password (tests/fixtures/v1.db)
    const household = "household";
    await page.fill("#si-in", household);
    await page.press("#si-in", "Enter");
    // First, the welcome to 2.0: what's new, and that the data came across
    await page.waitForSelector("#gate .w-wel #wel-go");
    ok(true, "upgrade: the 1.x household password signs in");
    const welcome = await gateText(page);
    ok(welcome.includes("Wally 2.0") && /\d+ transactions came across, checked one by one/.test(welcome), "upgrade: welcome to 2.0, with the transactions that came across");
    ok(await page.locator("#gate .w-welfeat li").count() === 4, "upgrade: four things that are new");
    const text = await page.evaluate(async () => (await fetch("/api/upgrade/report")).text());
    ok(text.includes("Kept, and checked one by one against the old database"), "upgrade: the downloaded report is plain text");
    await shot(page, "upgrade-welcome");
    await page.click("#wel-go");
    await page.waitForSelector("[data-name]");
    ok(await page.evaluate(() => localStorage.getItem("wally.welcomed")) === "1", "upgrade: this browser remembers the welcome was seen");
    await page.reload({ waitUntil: "networkidle" });
    await page.waitForSelector("[data-name]");
    ok(!(await page.locator("#gate .w-wel").count()), "upgrade: the welcome is shown once");
    ok((await gateText(page)).includes("Who’s in your household?"), "upgrade: asks who is in the household");
    await page.fill('[data-name="0"]', "Alex");
    await page.click("#onb-add");
    await page.keyboard.type("Ana");
    await shot(page, "upgrade-people");
    await page.click("#onb-next");
    await page.waitForSelector("[data-tagfor]");
    ok((await gateText(page)).includes("Did you tag personal expenses?") && (await gateText(page)).includes("like #alex"), "upgrade: offers to turn tags into people, with one of theirs as the example");
    const ids = await page.$$eval("[data-tagfor]", (l) => l.map((s) => s.dataset.tagfor));
    const picked = await page.$$eval("[data-tagfor]", (l) => l.map((s) => s.value));
    ok(picked.join() === "alex,ana", "upgrade: the tag with each person’s name is picked (" + picked + ")");
    ok(await page.$eval(`[data-tagfor="${ids[0]}"] option[value="ana"]`, (o) => o.disabled), "upgrade: a tag picked for Ana is greyed out for Alex");
    // The count only, without the examples under it
    const counts = () => page.$$eval("[id^=tagn-]:not(#tagn-rest)", (l) => l.map((e) => (e.hidden ? "" : e.id === "tagn-shared" ? e.textContent : e.firstChild ? e.firstChild.textContent : "")).join(" / "));
    await page.waitForFunction(() => document.getElementById("tagn-shared").textContent);
    ok((await counts()) === "1 transaction becomes theirs / 3 transactions become theirs / 1 transaction is tagged for more than one person, so it stays shared.",
      "upgrade: counted together, a transaction with both tags stays shared (" + (await counts()) + ")");
    ok((await page.locator(`#tagn-${ids[1]} .w-onbseen`).innerText()).includes("Coffee"), "upgrade: a few of them by name, so it's plain what the tag meant");
    await shot(page, "upgrade-tags");
    await page.selectOption(`[data-tagfor="${ids[0]}"]`, "");
    await page.waitForFunction(() => /4 transactions/.test(document.getElementById("gate").innerText));
    ok((await counts()) === "Nothing changes for Alex / 4 transactions become theirs / ", "upgrade: without Alex’s tag, it becomes Ana’s (" + (await counts()) + ")");
    await page.selectOption(`[data-tagfor="${ids[0]}"]`, "alex");
    await page.waitForFunction(() => document.getElementById("tagn-shared").textContent);
    ok(await page.locator(".w-onbdots li").count() === 4 && await page.locator(".w-onbfoot #onb-skip").innerText() === "Not now", "upgrade: four dots (people, tags, encrypting, the kit), and Not now under Continue");
    // The "You" row is this browser now: no signing out and in again
    const mid = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(mid.me && mid.me.name === "Alex", "upgrade: this browser became Alex, on the You row, without a second sign-in");
    // Back: nothing is lost, and the tags are applied only at the end
    await page.click("#onb-back");
    await page.waitForFunction(() => document.getElementById("gate").innerText.includes("Who’s in your household?"));
    ok((await page.$$eval("[data-name]", (l) => l.map((x) => x.value))).join() === "Alex,Ana" && !(await page.$("#onb-back")), "upgrade: back on People, the people already added, still editable, and no Back on the first step");
    // Someone added on the way back is saved on Continue, and removing them again takes them away
    await page.click("#onb-add");
    await page.fill('[data-name="2"]', "Sam");
    await page.click("#onb-next");
    await page.waitForSelector("[data-tagfor]");
    ok(await page.locator("[data-tagfor]").count() === 3 && (await page.evaluate(() => fetch("/api/people").then((r) => r.json()))).length === 3, "upgrade: back on People, someone added joins the household");
    await page.click("#onb-back");
    await page.waitForSelector('[data-drop="2"]');
    await page.click('[data-drop="2"]');
    await page.click("#onb-next");
    await page.waitForSelector("[data-tagfor]");
    ok(await page.locator("[data-tagfor]").count() === 2 && (await page.evaluate(() => fetch("/api/people").then((r) => r.json()))).map((x) => x.name).join() === "Alex,Ana", "upgrade: and removed again, they leave it");
    ok((await page.$$eval("[data-tagfor]", (l) => l.map((s) => s.value))).join() === "alex,ana", "upgrade: back on Tags, the picks are as they were left");
    await page.click("#onb-next");
    // Encrypting is offered, chosen by default, with Not now under it
    await page.waitForFunction(() => document.getElementById("gate").innerText.includes("Encrypt your data"));
    ok(await page.locator("#up-pw").count() === 1 && (await page.locator("#onb-next").innerText()) === "Encrypt" && await page.locator(".w-onbfoot #onb-skip").innerText() === "Not now",
      "upgrade: then encrypting, with the household password, and Not now");
    await shot(page, "upgrade-encrypt");
    await page.click("#onb-skip");
    // Not encrypting: no kit is needed, so the upgrade ends here and Wally opens
    ok(await appShown(page), "upgrade: not now: then the app, with no kit to save and no second sign-in");
    ok((await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()))).kit === null, "upgrade: no Emergency Kit, optional without encryption");
    await page.reload({ waitUntil: "networkidle" });
    ok(await appShown(page) && !(await page.locator("#onb-saved").count()), "upgrade: and nothing asks for one after a reload");
    const ust = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(ust.me && ust.me.name === "Alex", "upgrade: signed in as Alex");
    const usage = await page.evaluate((ids) => Promise.all(ids.map((id) => fetch("/api/people/" + id + "/usage").then((r) => r.json()))), ids);
    ok(usage[0].own === 1 && usage[1].own === 3, "upgrade: tagged transactions became theirs, the rest stay shared");
    const dinner = await page.evaluate(() => fetch("/api/transactions").then((r) => r.json()).then((l) => l.find((x) => x.name === "Dinner")));
    ok(dinner.person_id === null && dinner.tags.length === 0, "upgrade: tagged with both, it stays shared and loses the tags");
    noErrors(page, "upgrade");
    // Arabic, dark, on a phone: the sign-in screen mirrors
    await page.evaluate(() => fetch("/api/language/ar", { method: "PUT" }));
    await signOutVia(page);
    await page.waitForSelector("#si-in");
    await page.setViewportSize({ width: 390, height: 844 });
    await page.emulateMedia({ colorScheme: "dark" });
    ok(await page.evaluate(() => document.documentElement.dir) === "rtl", "arabic: right to left");
    await shot(page, "signin-ar-dark-phone");
    noErrors(page, "arabic");
    await page.context().close();
  }

  /* ================= upgrade from v1, encrypting on the way ================= */
  const v1e = await startServer(PORT + 3, { copy: join(REPO, "tests/fixtures/v1.db") });
  {
    const page = await open(v1e.base);
    await page.waitForSelector("#si-in");
    await page.fill("#si-in", "household");
    await page.press("#si-in", "Enter");
    await page.waitForSelector("#gate .w-wel #wel-go");
    await page.click("#wel-go");
    await page.waitForSelector("[data-name]");
    await page.fill('[data-name="0"]', "Alex");
    ok((await page.textContent("#up-note")) === "Everything already in Wally becomes yours.", "upgrade, on their own: everything becomes theirs");
    await page.click("#onb-next");
    // On their own, the tags have nobody else to go to: no step for them
    await page.waitForSelector("#up-pw");
    ok(!(await page.locator("[data-tagfor]").count()) && await page.locator(".w-onbdots li").count() === 3, "upgrade, on their own: no tags step, three dots");
    await page.click("#onb-next");
    ok((await page.textContent("#onb-err")).includes("Enter your password first"), "upgrade, encrypting: the password first");
    await page.fill("#up-pw", "not-it-at-all");
    await page.click("#onb-next");
    await page.waitForFunction(() => document.getElementById("onb-err").textContent.includes("password isn’t right"), null, { timeout: 15000 });
    ok(true, "upgrade, encrypting: a wrong password is refused, and nothing changes");
    await page.fill("#up-pw", "household");
    await page.click("#onb-next");
    await page.waitForSelector(".w-kitkey", { timeout: 30000 });
    ok((await gateText(page)).includes("Wally is encrypted."), "upgrade, encrypting: encrypted, then the Emergency Kit");
    ok(await page.isDisabled("#onb-next"), "upgrade, encrypting: Open Wally waits for the tick");
    await page.click("#onb-saved");
    await page.click("#onb-next");
    ok(await appShown(page), "upgrade, encrypting: then the app, unlocked, with no kit to save again");
    const est = await page.evaluate(() => fetch("/api/auth/status").then((r) => r.json()));
    ok(est.vault.on && !!est.kit && est.me.name === "Alex", "upgrade, encrypting: encrypted, with its kit, as Alex");
    const n = await page.evaluate(async () => (await import("/js/core.js")).state.tx.length);
    ok(n > 0 && await page.evaluate(async () => (await import("/js/core.js")).vaultOn()), "upgrade, encrypting: every transaction opens on this browser (" + n + ")");
    const mine = await page.evaluate(async () => { const c = await import("/js/core.js"); return c.state.tx.every((x) => x.p === c.state.me); });
    ok(mine, "upgrade, on their own: every transaction is Alex’s, settled before encrypting");
    // And turned off again from Settings
    await page.goto(v1e.base + "/settings/security", { waitUntil: "networkidle" });
    await page.waitForSelector("#pv-toggle");
    await page.click("#pv-toggle");
    const opened = await page.waitForSelector("#pv-pw", { timeout: 5000 }).then(() => true, () => false);
    ok(opened, "upgrade, encrypting: Settings opens Turn off encryption");
    if (opened) {
      await page.fill("#pv-pw", "household");
      await page.click("[data-dlg-ok]");
      const off = await page.waitForFunction(() => fetch("/api/auth/status").then((r) => r.json()).then((s) => !s.vault.on), null, { timeout: 60000, polling: 1000 }).then(() => true, () => false);
      const err = await page.locator("#dlg-err").innerText().catch(() => "");
      ok(off, "upgrade, encrypting: and turned off again" + (err ? " — " + err : ""));
    }
    noErrors(page, "upgrade, encrypting");
    await page.context().close();
  }
} catch (e) {
  failures++;
  console.log("  FAIL " + (e.stack || e.message));
} finally {
  await browser.close();
  servers.forEach((p) => p.kill());
}
console.log(failures ? failures + " failed" : "all passed");
process.exit(failures ? 1 : 0);