Ctrl K
ComunidadGuías…

tests/e2e/vault-parity.mjs

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

Archivo original ↗Preguntar a KiBot
/* The same requests to a household in the clear (answered by the server) and to an encrypted one
   (answered by js/vault/local.js in the browser): every answer, and everything stored after
   every step, must be the same. This is what keeps the two from drifting apart.
     REPO=/path/to/Wally PYTHON=/path/to/python PORT=8898 node vault-parity.mjs */
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const REPO = process.env.REPO || process.cwd();
const PYTHON = process.env.PYTHON || "python3";
const PORT = +(process.env.PORT || 8898);
let failures = 0, passed = 0;
const ok = (cond, msg) => { if (cond) passed++; else { failures++; console.log("  FAIL " + msg); } };
const servers = [];

async function startServer(port) {
  const data = mkdtempSync(join(tmpdir(), "wally-parity-"));
  const env = { ...process.env, WALLY_DATA: data };
  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/health"); if (r.ok) return base; } catch (e) { /* not yet */ }
    await new Promise((r) => setTimeout(r, 200));
  }
  throw new Error("server did not start");
}
function apiClient(base) {
  let cookie = "";
  return async (method, path, body, headers) => {
    const r = await fetch(base + "/api" + path, { method, headers: { "content-type": "application/json", cookie, ...(headers || {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
    for (const c of r.headers.getSetCookie ? r.headers.getSetCookie() : []) {
      const kv = c.split(";")[0], k = kv.split("=")[0];
      cookie = cookie.split("; ").filter((x) => x && !x.startsWith(k + "=")).concat([kv]).join("; ");
    }
    const text = await r.text();
    let data; try { data = JSON.parse(text); } catch (e) { data = text; }
    return { status: r.status, data };
  };
}

const D = (n) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10);
const browser = await chromium.launch();
try {
  const [P, E] = await Promise.all([startServer(PORT), startServer(PORT + 1)]);
  // The same household on both: two people, UTC, sign-in on
  const p = apiClient(P), e = apiClient(E);
  for (const c of [p, e]) {
    await c("POST", "/people", { name: "Alex" });
    await c("POST", "/people", { name: "Ana" });
    await c("PUT", "/config", { timezone: "UTC" });
    await c("PUT", "/signin", { enabled: true, password: "house-pass-1" }, { "x-wally-person": "1" });
  }
  await p("POST", "/login", { person_id: 1, password: "house-pass-1" });
  // The encrypted one: Alex turns encryption on in a browser, which then answers everything
  const ctx = await browser.newContext({ serviceWorkers: "block" });
  const page = await ctx.newPage();
  const errors = [];
  page.on("pageerror", (x) => errors.push(x.message));
  await page.goto(E + "/", { waitUntil: "networkidle" });
  await page.click('[data-prof="1"]');
  await page.fill("#si-in", "house-pass-1");
  await page.click("#si-go");
  await page.waitForSelector("#app:not([hidden])");
  await page.evaluate(async () => {
    const s = await import("/js/vault/session.js"); s.bind(await import("/js/core.js"));
    await s.turnOn("house-pass-1");
  });
  await page.reload({ waitUntil: "networkidle" });
  await page.waitForSelector("#app:not([hidden])");
  ok(await page.evaluate(async () => (await import("/js/core.js")).vaultOn()), "the browser answers for the encrypted household");
  const local = async (method, path, body) => page.evaluate(async ([m, pa, b]) => {
    const core = await import("/js/core.js");
    try { return { status: 200, data: await core.api(m, pa, b === null ? undefined : b) }; }
    catch (x) { return { status: x.status, data: { detail: x.detail } }; }
  }, [method, path, body === undefined ? null : body]);

  /* ---------- labels: ids differ on the two sides, so everything is named by how it came to be ---------- */
  const sides = { P: { map: new Map(), back: new Map() }, E: { map: new Map(), back: new Map() } };
  const hex = (id) => String(id).replace(/-/g, "").toLowerCase();
  const name = (side, id) => (id === null || id === undefined || id === "" ? id : sides[side].map.get(hex(id)) || "?" + hex(id).slice(0, 6));
  const real = (side, label) => { const r = sides[side].back.get(label); if (!r) throw new Error("no " + label + " on " + side); return r; };
  function learn(side, label, id) { sides[side].map.set(hex(id), label); sides[side].back.set(label, id); }

  const get = (side, path) => (side === "P" ? p("GET", path) : local("GET", path));
  async function state(side) {
    const [led, rules, cats, icons, tags, tagIcons, irules] = await Promise.all(["/ledger", "/recurring", "/categories", "/categories/icons", "/tags", "/tags/icons", "/rules"].map((x) => get(side, x)));
    return { led: led.data, rules: rules.data, cats: cats.data, icons: icons.data, tags: tags.data, tagIcons: tagIcons.data, irules: irules.data };
  }
  /* New transactions and rules since the last step get labels from what they are, the same on both sides */
  function labelNew(side, st, op) {
    const rules = st.rules.filter((r) => !sides[side].map.has(hex(r.id))).sort((a, b) => (a.startDate + a.name).localeCompare(b.startDate + b.name));
    rules.forEach((r, i) => learn(side, op + ":r" + i, r.id));
    const txs = st.led.rows.filter((r) => !sides[side].map.has(hex(r[0])));
    const keyOf = (r) => [r[1], r[2], String(r[4]).padStart(12, "0"), r[3], JSON.stringify(r.slice(7))].join("|");
    const occ = txs.filter((r) => r[12]), own = txs.filter((r) => !r[12]).sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
    for (const r of occ) learn(side, "occ:" + name(side, r[12]) + ":" + r[1], r[0]);
    own.forEach((r, i) => learn(side, op + ":t" + i, r[0]));
  }
  const groups = (side) => new Map();
  function norm(side, st) {
    const g = new Map();
    const rows = st.led.rows.map((r) => {
      const x = r.slice(); while (x.length < 17) x.push(null);
      const gid = x[14];
      if (gid && !g.has(gid)) g.set(gid, null);
      return { id: name(side, x[0]), date: x[1], name: x[2], cat: x[3], cents: x[4], type: x[5], person: x[7], by: x[8], shares: x[9], paid: x[10], tags: x[11], rule: name(side, x[12]), note: x[13], group: gid, ref: x[15], src: x[16] };
    });
    // A group is named after its first part
    for (const gid of g.keys()) g.set(gid, rows.filter((r) => r.group === gid).map((r) => r.id).sort()[0]);
    rows.forEach((r) => { r.group = r.group ? "g:" + g.get(r.group) : null; });
    rows.sort((a, b) => String(a.id).localeCompare(String(b.id)));
    const rules = st.rules.map((r) => ({ ...r, id: name(side, r.id), amount: Math.round(Number(r.amount) * 100), created_date: undefined })).sort((a, b) => String(a.id).localeCompare(String(b.id)));
    const irules = st.irules.map((r) => ({ match: r.match, category: r.category, person_id: r.person_id, set_person: r.set_person, uses: r.uses, learned: r.learned, position: r.position }));
    const icons = Object.fromEntries(Object.entries(st.icons).sort());
    return JSON.stringify({ rows, rules, cats: st.cats, icons, tags: st.tags, tagIcons: Object.fromEntries(Object.entries(st.tagIcons).sort()), irules });
  }
  const sortKeys = (v) => (v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : Array.isArray(v) ? v.map(sortKeys) : v);
  function normBody(side, body) {
    // The order of keys in an object means nothing to the app; the order of a list does
    return JSON.stringify(sortKeys(body), (k, v) => {
      if (k === "created_date" || k === "created_at") return undefined;
      if ((k === "id" || k === "recurringID" || k === "match_id") && typeof v === "string" && v.length >= 32) return name(side, v);
      if (k === "group_id" && v) return "group";
      if (k === "amount" && (typeof v === "string" || typeof v === "number")) return Math.round(Number(v) * 100);
      if (k === "rule_id") return v === null ? null : "rule";
      return v;
    });
  }

  let step = 0;
  /* One request on both sides. path and body may be functions of the side, for ids. */
  async function both(label, method, path, body, opts = {}) {
    step++;
    const res = {};
    for (const side of ["P", "E"]) {
      const pa = typeof path === "function" ? path(side) : path;
      const b = typeof body === "function" ? body(side) : body;
      res[side] = side === "P" ? await p(method, pa, b) : await local(method, pa, b);
    }
    const what = step + ". " + method + " " + (typeof path === "function" ? path("P").replace(/[0-9a-f-]{32,36}/g, "…") : path) + (label ? " (" + label + ")" : "");
    const okStatus = res.P.status === res.E.status || (res.P.status === 422 && res.E.status === 422);
    ok(okStatus, what + ": status " + res.P.status + " vs " + res.E.status + " — " + JSON.stringify(res.E.data).slice(0, 200) + " / " + JSON.stringify(res.P.data).slice(0, 200));
    if (res.P.status === 400 && res.E.status === 400 && !opts.anyMessage) ok(res.P.data.detail === res.E.data.detail, what + ": same message — " + JSON.stringify(res.P.data.detail) + " vs " + JSON.stringify(res.E.data.detail));
    const sts = {};
    for (const side of ["P", "E"]) { sts[side] = await state(side); labelNew(side, sts[side], label || "s" + step); }
    if (res.P.status === 200 && res.E.status === 200 && !opts.skipBody) {
      const a = normBody("P", res.P.data), c = normBody("E", res.E.data);
      ok(a === c, what + ": same answer\n      server:  " + a.slice(0, 600) + "\n      browser: " + c.slice(0, 600));
    }
    const sa = norm("P", sts.P), sb = norm("E", sts.E);
    if (sa !== sb) {
      let i = 0; while (i < sa.length && sa[i] === sb[i]) i++;
      ok(false, what + ": same data afterwards\n      server:  …" + sa.slice(Math.max(0, i - 200), i + 300) + "\n      browser: …" + sb.slice(Math.max(0, i - 200), i + 300));
    } else ok(true, what);
    return res;
  }
  const tx = (side, l) => real(side, l);
  const pub = async (side, l) => (await get(side, "/transactions/id/" + tx(side, l))).data[0];

  console.log("Transactions");
  await both("t1", "POST", "/transactions", { name: "Mercadona", category: "Groceries", amount: "23.45", date: D(3), paid_by: 1, tags: ["#weekly", " weekly ", "x"], note: "Café" });
  await both("t2", "POST", "/transactions", { name: "  Bar Pinotxo ", category: "Food", amount: "10.01", date: D(2) });
  await both("t3", "POST", "/transactions", { name: "Pharmacy", category: "Healthcare", amount: "7.5", date: D(2), person_id: 2, paid_by: 1, shares: { 2: 750 } });
  await both("t4", "POST", "/transactions", { name: "Dentist", category: "Therapy", amount: "80", date: D(4), shares: { 1: 2000, 2: 6000 } });
  await both("t5", "POST", "/transactions", { name: "Salary", category: "Income", amount: "2450.00", date: D(5), type: "income", paid_by: 2 });
  await both("bad shares", "POST", "/transactions", { name: "x", category: "Food", amount: "30", date: D(1), shares: { 1: 1000, 2: 1000 } });
  await both("no category", "POST", "/transactions", { name: "x", category: " ", amount: "3", date: D(1) });
  await both("zero", "POST", "/transactions", { name: "x", category: "Food", amount: "0", date: D(1) });
  await both("stranger", "POST", "/transactions", { name: "x", category: "Food", amount: "3", date: D(1), person_id: 9 });
  await both("g", "POST", "/transactions/group", [{ name: "IKEA", category: "Shopping", amount: "40", date: D(6) }, { name: "IKEA", category: "Food", amount: "4.5", date: D(6), person_id: 1 }]);
  await both("g1", "POST", "/transactions/group", [{ name: "One", category: "Food", amount: "1", date: D(6) }]);
  await both("", "PUT", (s) => "/transactions/" + tx(s, "t1:t0"), { amount: "7.03" });
  await both("", "PUT", (s) => "/transactions/" + tx(s, "t2:t0"), { person_id: 2 });
  await both("", "PUT", (s) => "/transactions/" + tx(s, "t4:t0"), { amount: "81", shares: { 1: 8000, 2: 100 } });
  await both("", "PUT", (s) => "/transactions/" + tx(s, "t1:t0"), { name: "Mercadona 2", category: "Brand new", tags: ["fresh"] });
  await both("", "PUT", (s) => "/transactions/" + tx(s, "t1:t0"), { amount: "0" });
  await both("", "PUT", (s) => "/transactions/" + tx(s, "t5:t0"), { type: "expense" });
  await both("", "PUT", "/transactions/00000000-0000-4000-8000-000000000000", { name: "nobody" });
  await both("", "POST", "/transactions/bulk", (s) => ({ ids: [tx(s, "t1:t0"), tx(s, "t2:t0"), tx(s, "t5:t0")], action: "update", category: "Food" }));
  await both("", "POST", "/transactions/bulk", (s) => ({ ids: [tx(s, "t2:t0"), tx(s, "t3:t0")], action: "update", person_id: null, set_person: true }));
  await both("", "POST", "/transactions/bulk", (s) => ({ ids: [tx(s, "t3:t0")], action: "update", person_id: 1, set_person: true }));
  const gone = { P: await pub("P", "g:t0"), E: await pub("E", "g:t0") };
  await both("", "DELETE", (s) => "/transactions/" + tx(s, "g:t0"));
  await both("", "DELETE", (s) => "/transactions/" + tx(s, "g:t0"));
  await both("restore", "POST", "/transactions/restore", (s) => [gone[s]], { skipBody: false });
  await both("", "POST", "/transactions/bulk", (s) => ({ ids: [tx(s, "g:t1"), tx(s, "t4:t0")], action: "delete" }));

  console.log("Recurring");
  await both("r1", "POST", "/recurring", { name: "Netflix", category: "Entertainment", amount: "13.99", startDate: D(100), frequency: "monthly", paid_by: 2 });
  await both("r2", "POST", "/recurring", { name: "Swim", category: "Healthcare", amount: "32", startDate: D(40), endDate: D(5), frequency: "weekly", interval: 2, person_id: 2 });
  await both("r3", "POST", "/recurring", { name: "Gym", category: "Healthcare", amount: "1.5", startDate: D(3), frequency: "daily", tags: ["#fit"] });
  await both("r4", "POST", "/recurring", { name: "Insurance", category: "Utilities", amount: "384", startDate: "2024-02-29", frequency: "yearly", paid_by: 1 });
  await both("r5", "POST", "/recurring", { name: "Rent", category: "Rent", amount: "1250", startDate: "2026-01-31", frequency: "monthly", paid_by: 1 });
  await both("bad rule", "POST", "/recurring", { name: "x", category: "Food", amount: "1", startDate: D(3), endDate: D(5), frequency: "daily" });
  await both("bad interval", "POST", "/recurring", { name: "x", category: "Food", amount: "1", startDate: D(3), frequency: "daily", interval: 0 });
  await both("later", "PUT", (s) => "/recurring/" + real(s, "r1:r0"), { amount: "15.99", applyTo: "later" });
  await both("later rent", "PUT", (s) => "/recurring/" + real(s, "r5:r0"), { amount: "1300", applyTo: "later" });
  await both("", "PUT", (s) => "/recurring/" + real(s, "r2:r0"), { name: "Swim club", category: "Sport", applyTo: "all" });
  await both("", "PUT", (s) => "/recurring/" + real(s, "r3:r0"), { startDate: D(5), applyTo: "all" });
  await both("", "PUT", (s) => "/recurring/" + real(s, "r3:r0"), { endDate: D(2), applyTo: "all" });
  await both("", "PUT", (s) => "/recurring/" + real(s, "r3:r0"), { startDate: D(1), applyTo: "later" });
  await both("", "PUT", (s) => "/recurring/" + real(s, "r4:r0"), { person_id: 2, applyTo: "all" });
  await both("", "POST", (s) => "/recurring/" + real(s, "later:r0") + "/pause");
  await both("", "POST", (s) => "/recurring/" + real(s, "later:r0") + "/resume");
  await both("", "POST", (s) => "/recurring/" + real(s, "later:r0") + "/missed", { add: true });
  await both("", "POST", (s) => "/recurring/" + real(s, "r2:r0") + "/end", { date: D(30) });
  await both("", "POST", (s) => "/recurring/" + real(s, "r2:r0") + "/end", { date: D(400) });
  await both("", "DELETE", (s) => "/recurring/" + real(s, "r3:r0"), { applyTo: "future" });
  await both("", "DELETE", (s) => "/recurring/" + real(s, "r2:r0"), { applyTo: "all" });

  console.log("Categories and tags");
  await both("", "POST", "/categories", { name: "Kids", color: 3 });
  await both("", "POST", "/categories", { name: "Kids" });
  await both("", "PUT", "/categories/Kids", { color: 5 });
  await both("", "PUT", "/categories/Kids", { icon: "Not an icon" }, { anyMessage: true });
  await both("", "PUT", "/categories/Food", { icon: "pizza" });
  await both("", "PUT", "/categories/Food", { name: "Eating out" });
  await both("", "GET", "/categories/icons");
  await both("", "PUT", "/categories/Eating%20out", { icon: "" });
  await both("", "PUT", "/categories/Eating%20out", { icon: "coffee", color: 2 });
  await both("", "PUT", "/categories/Eating%20out", { name: "Groceries" });
  await both("", "GET", "/categories/Eating%20out/usage");
  await both("", "DELETE", "/categories/Eating%20out", undefined, { anyMessage: false });
  await both("", "DELETE", "/categories/Eating%20out?move_to=Groceries");
  await both("", "DELETE", "/categories/Kids");
  await both("", "POST", "/tags", { name: "holiday" });
  await both("", "POST", "/tags", { name: "holiday" });
  await both("", "PUT", "/tags/weekly", { name: "#week" });
  await both("", "PUT", "/tags/x", { name: "week" });
  await both("", "PUT", "/tags/week", { icon: "Not an icon" }, { anyMessage: true });
  await both("", "PUT", "/tags/week", { icon: "palm" });
  await both("", "PUT", "/tags/week", { name: "weekly" });
  await both("", "GET", "/tags/icons");
  await both("", "PUT", "/tags/weekly", { icon: "" });
  await both("", "PUT", "/tags/weekly", { icon: "car", name: "week" });
  await both("", "PUT", "/tags/holiday", { icon: "palm" });
  await both("", "PUT", "/tags/holiday", { name: "week" });
  await both("", "POST", "/tags", { name: "holiday" });
  await both("", "PUT", "/tags/week", { icon: "" });
  await both("", "DELETE", "/tags/fresh");
  await both("", "DELETE", "/tags/nothing");

  console.log("Import");
  const rows = [
    { date: D(2), name: "BAR PINOTXO BARCELONA", amount: "-10.01", type: "expense" },
    { date: D(3), name: "MERCADONA 4521 BCN", amount: "7.03" },
    { date: D(6), name: "SOMETHING ELSE", amount: "-40.00" },
    { date: D(9), name: "Netflix.com", amount: "-13.99" },
    { date: D(1), name: "Glovo 22", amount: "18.20", category: "", person: "shared" },
    { date: D(1), name: "Glovo 22", amount: "18.20", person: "ana" },
    { date: D(1), name: "Uber", amount: "5", tags: ["#trip"], paid_by: "2" },
  ];
  const prev = await both("", "POST", "/import/preview", rows);
  await both("", "POST", "/import/preview", []);
  const commit = (s) => ({ rows: rows.map((r, i) => {
    const pr = (s === "P" ? prev.P : prev.E).data.rows[i];
    return { ...r, import_ref: pr.import_ref, action: pr.status === "matched" || pr.status === "possible" ? "match" : pr.status === "skipped" ? "skip" : "add", match_id: pr.match ? pr.match.id : null, category: pr.category || r.category || "", person_id: pr.person_id, shared: pr.shared };
  }) });
  await both("imp", "POST", "/import/commit", commit);
  await both("", "POST", "/import/commit", commit);
  await both("", "POST", "/import/preview", rows);
  const imported = (s) => (s === "P" ? null : null);
  await both("learn", "PUT", (s) => "/transactions/" + real(s, "imp:t0"), { category: "Transport", person_id: 2 });
  await both("", "POST", "/import/preview", [{ date: D(0), name: "UBER TRIP 33", amount: "-9" }, { date: D(0), name: "uber", amount: "-9" }]);

  console.log("People");
  await both("", "GET", "/people/2/usage");
  await both("", "GET", "/people/from-tag/preview?tag=week");
  await both("", "POST", "/people/2/from-tag", { tag: "week", remove_tag: true });
  await both("", "POST", "/people/from-tags/preview", { tags: { holiday: 1, fit: 2 }, remove_tag: false });
  ok((await p("POST", "/people", { name: "Sam" })).status === 200, "Sam joins the household in the clear, with only a name");
  ok(await page.evaluate(async () => { const c = await import("/js/core.js"); await c.POST("/people", { name: "Sam" }); c.state.people = await c.GET("/people"); return c.state.people.length; }) === 3, "and the encrypted one");
  await both("sam", "POST", "/transactions", { name: "Pizza", category: "Food", amount: "30.01", date: D(1), paid_by: 3 });
  await both("", "POST", "/transactions", { name: "Cinema", category: "Entertainment", amount: "12", date: D(1), person_id: 3 });
  await both("", "POST", "/recurring", { name: "Phone", category: "Utilities", amount: "20", startDate: D(10), frequency: "monthly", person_id: 3 });
  await both("", "GET", "/people/3/usage");
  await both("", "DELETE", "/people/3", { move_to: null });
  await page.evaluate(async () => { const c = await import("/js/core.js"); c.state.people = await c.GET("/people"); });
  await both("after", "POST", "/transactions", { name: "After", category: "Food", amount: "9.99", date: D(0) });
  await both("", "DELETE", "/people/2", { move_to: 1 });

  ok(errors.length === 0, "no errors in the browser" + (errors.length ? ": " + errors.join(" | ") : ""));
} catch (x) {
  failures++;
  console.log("  FAIL " + (x.stack || x));
} finally {
  await browser.close();
  servers.forEach((s) => s.kill("SIGKILL"));
}
console.log(failures ? "\n" + failures + " failed, " + passed + " passed" : "\nAll " + passed + " parity checks passed.");
process.exit(failures ? 1 : 0);