diff --git a/apps/pwa/src/routes/cli.mjs b/apps/pwa/src/routes/cli.mjs index b7a6ed7..478f440 100644 --- a/apps/pwa/src/routes/cli.mjs +++ b/apps/pwa/src/routes/cli.mjs @@ -32,13 +32,16 @@ function loopbackOk(uri) { } catch { return false; } } -cliRouter.get("/cli/authorize", requireAuth, (req, res) => { +cliRouter.get("/cli/authorize", requireAuth, async (req, res) => { const { redirect_uri, state, code_challenge } = req.query; if (!loopbackOk(redirect_uri) || !state || !code_challenge) { return res.status(400).type("html").send(page({ body: `

Bad CLI request

missing/invalid redirect_uri, state, or code_challenge.

` })); } const name = String(req.query.name || "moshcode cli").slice(0, 40); - const body = `${appBar(req.user, 0, req.csrfToken)} + // The app bar's credit chip is a live readout, not decoration — pass the + // real balance, the way every other signed-in page does. A hardcoded 0 tells + // a funded account it is broke on the page where it authorizes the CLI. + const body = `${appBar(req.user, await balance(req.user.id), req.csrfToken)}
🔑
@@ -125,11 +128,11 @@ cliRouter.post("/cli/device/code", async (req, res) => { }); // The page a human opens to approve a device. -cliRouter.get("/device", requireAuth, (req, res) => { +cliRouter.get("/device", requireAuth, async (req, res) => { const prefill = req.query.code ? normCode(req.query.code) : ""; const done = req.query.done; const bad = req.query.bad; - const body = `${appBar(req.user, 0, req.csrfToken)} + const body = `${appBar(req.user, await balance(req.user.id), req.csrfToken)}
🔑
diff --git a/apps/pwa/test/cli-pages-balance.test.mjs b/apps/pwa/test/cli-pages-balance.test.mjs new file mode 100644 index 0000000..c186356 --- /dev/null +++ b/apps/pwa/test/cli-pages-balance.test.mjs @@ -0,0 +1,120 @@ +// Integration tests for the credit chip on the two CLI-connect pages. +// +// appBar(user, balance, csrf) renders a "◆ cr" chip for every signed-in +// visitor. Every other signed-in page passes the account's real balance, so +// the chip is a live readout of what the account can spend. The two CLI pages +// (/cli/authorize and /device) must do the same: a chip that always says 0 +// tells a funded account it is broke on the page where it is deciding whether +// to connect a device. +// +// Boots the real routers against a throwaway libsql file database; skips +// cleanly when the PWA dependencies are not installed. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express"), cookieParser: require("cookie-parser") }; +} catch { + deps = null; // pwa dependencies not installed — tests below skip +} + +// Point the app at a throwaway database BEFORE importing its modules (config +// reads the environment once, at import time). +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pwa-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const CSRF = "test-csrf-token"; +const SESSION = "test-session-token"; +const CREDITS = 4321; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, get, db } = await import("../src/db.mjs"); + const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs"); + const { cliRouter } = await import("../src/routes/cli.mjs"); + const { pagesRouter } = await import("../src/routes/pages.mjs"); + + const app = deps.express(); + app.use(deps.express.json()); + app.use(deps.express.urlencoded({ extended: false })); + app.use(deps.cookieParser()); + app.use(sessionMiddleware); + app.use(csrfGuard); + app.use(cliRouter); + app.use(pagesRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const cookies = `mc_sess=${SESSION}; mc_csrf=${CSRF}`; + + await run(`INSERT INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','demo',1)`); + await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`, + [SESSION, "u1", Date.now(), Date.now() + 60_000]); + await run(`INSERT INTO credit_ledger (id,user_id,delta,reason,created_at) VALUES (?,?,?,?,?)`, + ["led-1", "u1", CREDITS, "test.seed", Date.now()]); + + return { run, get, db, server, base, cookies }; +} + +let booted = null; +const app = () => (booted ||= boot()); + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +// The chip is rendered as: 4,321 cr +// toLocaleString() puts separators in, so read the digits back out. +function chipBalance(html) { + const m = /[^<]*([^<]+)<\/b>/.exec(html); + assert.ok(m, "page must render the app-bar balance chip"); + return Number(m[1].replace(/[^0-9-]/g, "")); +} + +const AUTHORIZE = "/cli/authorize?redirect_uri=" + encodeURIComponent("http://127.0.0.1:9999/cb") + + "&state=st&code_challenge=ch&name=moshcode+cli"; + +test("GET /cli/authorize shows the account's real balance", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { base, cookies } = await app(); + const res = await fetch(`${base}${AUTHORIZE}`, { headers: { cookie: cookies } }); + assert.equal(res.status, 200); + assert.equal(chipBalance(await res.text()), CREDITS); +}); + +test("GET /device shows the account's real balance", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { base, cookies } = await app(); + const res = await fetch(`${base}/device`, { headers: { cookie: cookies } }); + assert.equal(res.status, 200); + assert.equal(chipBalance(await res.text()), CREDITS); +}); + +// Control: the dashboard already reads the ledger, so this passes either way. +// It proves the seeded balance is really 4321 and that the chip parser works, +// which is what makes the two failures above meaningful. +test("control: the dashboard chip already reads the ledger", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { base, cookies } = await app(); + const res = await fetch(`${base}/dashboard`, { headers: { cookie: cookies } }); + assert.equal(res.status, 200); + assert.equal(chipBalance(await res.text()), CREDITS); +}); + +// Control: the CLI pages must keep working, chip aside. A signed-in visitor +// gets the approve form (with its CSRF field), not an error page. +test("control: /cli/authorize still renders the approve form", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { base, cookies } = await app(); + const html = await (await fetch(`${base}${AUTHORIZE}`, { headers: { cookie: cookies } })).text(); + assert.match(html, /