Skip to content

Commit 5c4e753

Browse files
fix(approvals): stop a non-object context rendering one cell per character (#63)
The approve page builds its context grid with Object.entries(JSON.parse(a.context)). POST /api/approvals stores context as JSON.stringify(req.body.context) verbatim, so it is whatever the caller sent, not necessarily a label/value map. A string context indexes per character: context:"deploy failed on prod" rendered 21 grid cells labelled 0 to 20, one letter each. A number or boolean context yields no entries at all, so the value silently vanished from the page. Only a real object is a map. Anything else now renders as a single "context" cell. Objects and arrays are untouched. Regression test: apps/pwa/test/approvals-context.test.mjs boots the real router against a throwaway libsql database and asserts the grid for object, string, number, boolean, array and absent contexts. Two of the five fail without the fix. Co-authored-by: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com>
1 parent 86a3fa8 commit 5c4e753

2 files changed

Lines changed: 123 additions & 1 deletion

File tree

apps/pwa/src/routes/approvals.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,14 @@ approvalsRouter.get("/approve/:id", async (req, res) => {
109109
if (!a) return res.status(404).type("html").send(page({ body: `<main class="wrap" style="padding-top:12vh"><h1>404 — no such approval</h1></main>` }));
110110
if (!canView(req, a)) return res.status(403).type("html").send(page({ body: `<main class="wrap" style="padding-top:12vh"><h1>403 — not your pit</h1></main>` }));
111111

112-
const ctx = a.context ? JSON.parse(a.context) : {};
112+
// `context` is stored as whatever the client sent — POST /api/approvals
113+
// JSON.stringifies the value verbatim — so it is not necessarily a
114+
// label→value map. Object.entries() on a string indexes it per character, so
115+
// context:"deploy failed on prod" rendered 21 grid cells, one per letter; on
116+
// a number or boolean it yields nothing and the value vanished from the page.
117+
// Only a real object is a map; anything else is a single value.
118+
const raw = a.context ? JSON.parse(a.context) : {};
119+
const ctx = raw !== null && typeof raw === "object" ? raw : { context: raw };
113120
const done = a.status !== "pending";
114121
const cells = Object.entries(ctx).map(([k, v]) =>
115122
`<div style="background:var(--surface);padding:11px 13px;border:1px solid var(--line);border-radius:8px">
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// The approve page's context grid is a label→value map, so it must only build
2+
// cells from a real object. POST /api/approvals stores whatever the client
3+
// sent, and Object.entries() on a string indexes it per character.
4+
//
5+
// These boot the real router against a throwaway libsql file database. They
6+
// skip cleanly when the PWA dependencies are not installed (a fresh repo clone
7+
// only has the root CLI deps), so the root `npm test` stays green either way.
8+
// Run `npm install` in apps/pwa to enable them.
9+
import assert from "node:assert/strict";
10+
import { mkdtempSync } from "node:fs";
11+
import { tmpdir } from "node:os";
12+
import path from "node:path";
13+
import { createRequire } from "node:module";
14+
import test from "node:test";
15+
16+
const require = createRequire(import.meta.url);
17+
let deps = null;
18+
try {
19+
deps = { express: require("express"), cookieParser: require("cookie-parser") };
20+
} catch {
21+
deps = null; // pwa dependencies not installed — tests below skip
22+
}
23+
24+
// Point the app at a throwaway database BEFORE importing its modules (config
25+
// reads the environment once, at import time).
26+
const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pwa-context-test-"));
27+
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
28+
process.env.SESSION_SECRET = "test-secret";
29+
30+
async function boot() {
31+
const { migrate } = await import("../src/migrate.mjs");
32+
await migrate();
33+
const { run } = await import("../src/db.mjs");
34+
const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
35+
const { approvalsRouter } = await import("../src/routes/approvals.mjs");
36+
const { createApiKey } = await import("../src/lib/apikey.mjs");
37+
const { id } = await import("../src/lib/crypto.mjs");
38+
39+
const app = deps.express();
40+
app.use(deps.express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
41+
app.use(deps.express.urlencoded({ extended: false }));
42+
app.use(deps.cookieParser());
43+
app.use(sessionMiddleware);
44+
app.use(csrfGuard);
45+
app.use(approvalsRouter);
46+
const server = await new Promise((resolve) => {
47+
const s = app.listen(0, "127.0.0.1", () => resolve(s));
48+
});
49+
const base = `http://127.0.0.1:${server.address().port}`;
50+
51+
const userId = id();
52+
await run(`INSERT INTO users (id, email, display_name, created_at) VALUES (?,?,?,?)`,
53+
[userId, `${userId}@b.c`, "demo", Date.now()]);
54+
const { plaintext } = await createApiKey(userId, "cli");
55+
56+
// POST an approval carrying `context`, then read the page the operator opens
57+
// and pull out the (label, value) pairs of the context grid.
58+
const cellsFor = async (context) => {
59+
const res = await fetch(`${base}/api/approvals`, {
60+
method: "POST",
61+
headers: { authorization: `Bearer ${plaintext}`, "content-type": "application/json" },
62+
body: JSON.stringify({ message: "ship it?", kind: "ask", script: "ship.mosh", context }),
63+
});
64+
assert.equal(res.status, 201);
65+
const { id: approvalId, url } = await res.json();
66+
const cap = new URL(url).searchParams.get("t");
67+
const page = await fetch(`${base}/approve/${approvalId}?t=${cap}`);
68+
assert.equal(page.status, 200);
69+
const html = await page.text();
70+
return [...html.matchAll(/<div class="label" style="font-size:.6rem">([^<]*)<\/div><div class="mono" style="margin-top:3px">([^<]*)</g)]
71+
.map((m) => [m[1], m[2]]);
72+
};
73+
74+
return { cellsFor, close: () => server.close() };
75+
}
76+
77+
test("an object context still renders one cell per key", { skip: !deps && "pwa deps not installed" }, async () => {
78+
const app = await boot();
79+
try {
80+
assert.deepEqual(await app.cellsFor({ env: "prod", sha: "a1b2c3" }), [["env", "prod"], ["sha", "a1b2c3"]]);
81+
} finally { app.close(); }
82+
});
83+
84+
test("a string context is one cell, not one cell per character", { skip: !deps && "pwa deps not installed" }, async () => {
85+
const app = await boot();
86+
try {
87+
// Before the fix this was 21 cells labelled 0…20, one letter each.
88+
assert.deepEqual(await app.cellsFor("deploy failed on prod"), [["context", "deploy failed on prod"]]);
89+
} finally { app.close(); }
90+
});
91+
92+
test("a number or boolean context is shown, not silently dropped", { skip: !deps && "pwa deps not installed" }, async () => {
93+
const app = await boot();
94+
try {
95+
// Object.entries(42) and Object.entries(true) are both [], so the value the
96+
// caller sent never reached the page at all.
97+
assert.deepEqual(await app.cellsFor(42), [["context", "42"]]);
98+
assert.deepEqual(await app.cellsFor(true), [["context", "true"]]);
99+
} finally { app.close(); }
100+
});
101+
102+
test("an array context keeps its index-labelled cells", { skip: !deps && "pwa deps not installed" }, async () => {
103+
const app = await boot();
104+
try {
105+
// An array is an object, so it is left exactly as it rendered before.
106+
assert.deepEqual(await app.cellsFor(["first", "second"]), [["0", "first"], ["1", "second"]]);
107+
} finally { app.close(); }
108+
});
109+
110+
test("no context renders no grid at all", { skip: !deps && "pwa deps not installed" }, async () => {
111+
const app = await boot();
112+
try {
113+
assert.deepEqual(await app.cellsFor(undefined), []);
114+
} finally { app.close(); }
115+
});

0 commit comments

Comments
 (0)