Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/pwa/src/routes/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<main class="wrap" style="padding-top:12vh"><h1>Bad CLI request</h1><p class="dim mono">missing/invalid redirect_uri, state, or code_challenge.</p></main>` }));
}
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)}
<main class="wrap" style="max-width:460px;padding-top:8vh">
<div class="card"><div class="card-body" style="text-align:center">
<div style="font-size:2rem">🔑</div>
Expand Down Expand Up @@ -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)}
<main class="wrap" style="max-width:440px;padding-top:8vh">
<div class="card"><div class="card-body" style="text-align:center">
<div style="font-size:2rem">🔑</div>
Expand Down
120 changes: 120 additions & 0 deletions apps/pwa/test/cli-pages-balance.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Integration tests for the credit chip on the two CLI-connect pages.
//
// appBar(user, balance, csrf) renders a "◆ <n> 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: <span class="bal-chip">◆ <b>4,321</b> cr</span>
// toLocaleString() puts separators in, so read the digits back out.
function chipBalance(html) {
const m = /<span class="bal-chip">[^<]*<b>([^<]+)<\/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, /<form method="post" action="\/cli\/authorize"/);
assert.match(html, /name="_csrf" value="test-csrf-token"/);
});
Loading