// Integration test for T24 (Stripe topup, mock fallback), dev-plan-4.md. // Spawns server.js on a throwaway port against an isolated tmpdir copy, // same isolation approach as auth.test.js. const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const os = require("node:os"); const crypto = require("node:crypto"); const { spawn } = require("node:child_process"); function waitForServer(url, timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; return (async function poll() { while (Date.now() < deadline) { try { await fetch(url); return; } catch { await new Promise((r) => setTimeout(r, 100)); } } throw new Error("server did not come up in time"); })(); } function sessionCookie(res) { const setCookie = res.headers.get("set-cookie") || ""; return setCookie.split(";")[0]; } function makeTmpCwd() { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "credits-test-")); for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } return cwd; } function spawnServer(port, extraEnv = {}) { return spawn(process.execPath, ["server.js"], { cwd: makeTmpCwd(), env: { ...process.env, PORT: String(port), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD: "unused", ...extraEnv }, stdio: "pipe", }); } async function registerVerifiedUser(base) { const email = `credits-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`; const registerRes = await fetch(`${base}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password: "correct-horse-battery" }), }); const { devVerifyUrl } = await registerRes.json(); const cookie = sessionCookie(registerRes); await fetch(`${base}${devVerifyUrl}`); return cookie; } const PORT = 27934 + Math.floor(Math.random() * 1000); const BASE = `http://localhost:${PORT}`; let child; test.before(async () => { child = spawnServer(PORT); await waitForServer(BASE); }); test.after(() => child.kill()); test("checkout requires login", async () => { const res = await fetch(`${BASE}/api/credits/checkout`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tierKey: "topup_tier_1" }), }); assert.equal(res.status, 401); }); test("checkout requires a verified email, not just a login", async () => { const email = `unverified-${Date.now()}@example.com`; const registerRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password: "correct-horse-battery" }), }); const cookie = sessionCookie(registerRes); const res = await fetch(`${BASE}/api/credits/checkout`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ tierKey: "topup_tier_1" }), }); assert.equal(res.status, 403); }); test("checkout rejects an unknown tierKey", async () => { const cookie = await registerVerifiedUser(BASE); const res = await fetch(`${BASE}/api/credits/checkout`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ tierKey: "not-a-real-tier" }), }); assert.equal(res.status, 400); }); test("with no STRIPE_SECRET_KEY configured, checkout returns a same-origin mock checkout URL, and visiting it credits the account exactly once", async () => { const cookie = await registerVerifiedUser(BASE); const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.equal(meBefore.creditBalance, 0); const checkoutRes = await fetch(`${BASE}/api/credits/checkout`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ tierKey: "topup_tier_1" }), }); assert.equal(checkoutRes.status, 200); const { url } = await checkoutRes.json(); assert.match(url, /^\/api\/credits\/mock-checkout\?token=/); const completeRes = await fetch(`${BASE}${url}`, { redirect: "manual" }); assert.equal(completeRes.status, 302); assert.match(completeRes.headers.get("location"), /^\/dance\.html\?topup=success/); const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.ok(meAfter.creditBalance > 0, "mock checkout completion must actually credit the account"); // single-use — visiting the same completion URL again must not double-credit const secondRes = await fetch(`${BASE}${url}`, { redirect: "manual" }); assert.equal(secondRes.status, 400); const meFinal = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.equal(meFinal.creditBalance, meAfter.creditBalance); }); test("visiting an unknown/garbage mock-checkout token is a clear error, not a silent success", async () => { const res = await fetch(`${BASE}/api/credits/mock-checkout?token=not-a-real-token`, { redirect: "manual" }); assert.equal(res.status, 400); }); // ---- webhook: needs its own server instance with STRIPE_WEBHOOK_SECRET // set, since the default (unconfigured) instance above must refuse all // webhook posts regardless of signature ---- test("without STRIPE_WEBHOOK_SECRET configured, the webhook endpoint refuses everything, signed or not", async () => { const res = await fetch(`${BASE}/api/credits/webhook`, { method: "POST", headers: { "Content-Type": "application/json", "Stripe-Signature": "t=1,v1=deadbeef" }, body: JSON.stringify({ type: "checkout.session.completed" }), }); assert.equal(res.status, 400); }); function stripeSignatureHeader(payload, secret, timestamp = Math.floor(Date.now() / 1000)) { const v1 = crypto.createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex"); return `t=${timestamp},v1=${v1}`; } test("webhook: correctly-signed checkout.session.completed credits the account; an unsigned or mis-signed request is rejected; the same event id redelivered doesn't double-credit", async () => { const secret = "whsec_test_only"; const port = 28934 + Math.floor(Math.random() * 1000); const base = `http://localhost:${port}`; const webhookChild = spawnServer(port, { STRIPE_WEBHOOK_SECRET: secret }); try { await waitForServer(base); const cookie = await registerVerifiedUser(base); const { id: userId } = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); const payload = JSON.stringify({ id: "evt_test_1", type: "checkout.session.completed", data: { object: { id: "cs_test_1", metadata: { userId, tierKey: "topup_tier_1" } } }, }); // unsigned const unsignedRes = await fetch(`${base}/api/credits/webhook`, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload }); assert.equal(unsignedRes.status, 400); // mis-signed (right shape, wrong secret) const wrongSig = stripeSignatureHeader(payload, "whsec_wrong"); const wrongRes = await fetch(`${base}/api/credits/webhook`, { method: "POST", headers: { "Content-Type": "application/json", "Stripe-Signature": wrongSig }, body: payload, }); assert.equal(wrongRes.status, 400); // correctly signed const goodSig = stripeSignatureHeader(payload, secret); const goodRes = await fetch(`${base}/api/credits/webhook`, { method: "POST", headers: { "Content-Type": "application/json", "Stripe-Signature": goodSig }, body: payload, }); assert.equal(goodRes.status, 200); const meAfterFirst = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.ok(meAfterFirst.creditBalance > 0); // Stripe's guaranteed at-least-once delivery: the exact same event // (same id) can arrive again and must not double-credit. const redeliveredRes = await fetch(`${base}/api/credits/webhook`, { method: "POST", headers: { "Content-Type": "application/json", "Stripe-Signature": goodSig }, body: payload, }); assert.equal(redeliveredRes.status, 200); const meAfterRedelivery = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.equal(meAfterRedelivery.creditBalance, meAfterFirst.creditBalance); } finally { webhookChild.kill(); } });