Skip to content

Commit 20e4a48

Browse files
committed
fix(pwa): look up credit packs by own property only
PACKS is a plain object literal, so /credits/buy with pack=constructor (or toString, hasOwnProperty, …) resolved off Object.prototype — truthy, so the starter fallback was skipped and the CoinPay payment was created with no amount and no credits. Same class as the engine/tool resolver fix (#48).
1 parent 5029919 commit 20e4a48

2 files changed

Lines changed: 127 additions & 1 deletion

File tree

apps/pwa/src/routes/credits.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ export const PACKS = {
1717
};
1818

1919
creditsRouter.post("/credits/buy", requireAuth, async (req, res) => {
20-
const pack = PACKS[req.body.pack] || PACKS.starter;
20+
// Own properties only: PACKS is a plain object literal, so a pack name like
21+
// `constructor` or `toString` would otherwise resolve to something off
22+
// Object.prototype — truthy, so the starter fallback is skipped and the
23+
// payment goes out with no amount/credits.
24+
const pack = (req.body.pack && Object.hasOwn(PACKS, req.body.pack)) ? PACKS[req.body.pack] : PACKS.starter;
2125
if (!config.coinpay.businessId) {
2226
// not wired yet — tell the user instead of failing silently
2327
return res.redirect("/settings?err=coinpay-not-configured");
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Integration test for the credit-pack lookup in POST /credits/buy.
2+
//
3+
// PACKS is a plain object literal, so `PACKS[req.body.pack]` resolves names
4+
// off Object.prototype too: pack=constructor (or toString, hasOwnProperty, …)
5+
// is truthy, skips the starter fallback, and the CoinPay payment goes out
6+
// with no amount and no credits.
7+
//
8+
// Boots the real router against a throwaway libsql file database plus a stub
9+
// CoinPay API; skips cleanly when the PWA dependencies are not installed.
10+
import assert from "node:assert/strict";
11+
import http from "node:http";
12+
import fs from "node:fs";
13+
import { mkdtempSync } from "node:fs";
14+
import { tmpdir } from "node:os";
15+
import path from "node:path";
16+
import { createRequire } from "node:module";
17+
import test from "node:test";
18+
19+
const require = createRequire(import.meta.url);
20+
let deps = null;
21+
try {
22+
deps = { express: require("express"), cookieParser: require("cookie-parser") };
23+
} catch {
24+
deps = null; // pwa dependencies not installed — tests below skip
25+
}
26+
27+
const CSRF = "test-csrf-token";
28+
const SESSION = "test-session-token";
29+
30+
// What the stub CoinPay API last received on /api/payments/create.
31+
const received = [];
32+
33+
async function boot() {
34+
// Stub CoinPay API first: config reads COINPAY_API_BASE once, at import time.
35+
const stub = await new Promise((resolve) => {
36+
const s = http.createServer((req, res) => {
37+
let data = "";
38+
req.on("data", (chunk) => { data += chunk; });
39+
req.on("end", () => {
40+
received.push(JSON.parse(data));
41+
res.writeHead(200, { "content-type": "application/json" });
42+
res.end(JSON.stringify({ id: "pay_1", hosted_url: "http://coinpay.test/pay/pay_1" }));
43+
});
44+
});
45+
s.listen(0, "127.0.0.1", () => resolve(s));
46+
});
47+
const stubPort = stub.address().port;
48+
49+
const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pwa-test-"));
50+
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
51+
process.env.SESSION_SECRET = "test-secret";
52+
process.env.COINPAY_BUSINESS_ID = "biz_test";
53+
process.env.COINPAY_API_BASE = `http://127.0.0.1:${stubPort}`;
54+
55+
const { migrate } = await import("../src/migrate.mjs");
56+
await migrate();
57+
const { run, db } = await import("../src/db.mjs");
58+
const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
59+
const { creditsRouter } = await import("../src/routes/credits.mjs");
60+
61+
const app = deps.express();
62+
app.use(deps.express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
63+
app.use(deps.express.urlencoded({ extended: false }));
64+
app.use(deps.cookieParser());
65+
app.use(sessionMiddleware);
66+
app.use(csrfGuard);
67+
app.use(creditsRouter);
68+
const server = await new Promise((resolve) => {
69+
const s = app.listen(0, "127.0.0.1", () => resolve(s));
70+
});
71+
const base = `http://127.0.0.1:${server.address().port}`;
72+
const cookies = `mc_sess=${SESSION}; mc_csrf=${CSRF}`;
73+
74+
await run(`INSERT INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','demo',1)`);
75+
await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`,
76+
[SESSION, "u1", Date.now(), Date.now() + 60_000]);
77+
78+
const buy = (pack) => fetch(`${base}/credits/buy`, {
79+
method: "POST",
80+
headers: { cookie: cookies, "content-type": "application/x-www-form-urlencoded" },
81+
body: `_csrf=${CSRF}&pack=${encodeURIComponent(pack)}`,
82+
redirect: "manual",
83+
});
84+
85+
return { run, db, server, stub, base, cookies, buy, workdir };
86+
}
87+
88+
let booted = null;
89+
const app = () => (booted ||= boot());
90+
91+
test.after(() => {
92+
if (!booted) return;
93+
booted.then(({ server, stub, db, workdir }) => { server.close(); stub.close(); db.close?.(); })
94+
.finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } });
95+
});
96+
97+
test("credits/buy: a named pack buys that pack", { skip: !deps && "apps/pwa deps not installed" }, async () => {
98+
const { buy } = await app();
99+
const res = await buy("pro");
100+
assert.equal(res.status, 302);
101+
const sent = received.at(-1);
102+
assert.equal(sent.amount, 20);
103+
assert.equal(sent.metadata.credits, 5000);
104+
});
105+
106+
test("credits/buy: an unknown pack falls back to starter", { skip: !deps && "apps/pwa deps not installed" }, async () => {
107+
const { buy } = await app();
108+
const res = await buy("bogus");
109+
assert.equal(res.status, 302);
110+
const sent = received.at(-1);
111+
assert.equal(sent.amount, 5);
112+
assert.equal(sent.metadata.credits, 1000);
113+
});
114+
115+
test("credits/buy: an Object.prototype name falls back to starter too", { skip: !deps && "apps/pwa deps not installed" }, async () => {
116+
const { buy } = await app();
117+
const res = await buy("constructor");
118+
assert.equal(res.status, 302);
119+
const sent = received.at(-1);
120+
assert.equal(sent.amount, 5, "pack=constructor must not reach CoinPay with a missing amount");
121+
assert.equal(sent.metadata.credits, 1000);
122+
});

0 commit comments

Comments
 (0)