/* 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);