diff --git a/credits.test.js b/credits.test.js new file mode 100644 index 0000000..c844656 --- /dev/null +++ b/credits.test.js @@ -0,0 +1,210 @@ +// 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(); + } +}); diff --git a/server.js b/server.js index c0ad96c..95376e0 100644 --- a/server.js +++ b/server.js @@ -29,6 +29,13 @@ const GLM_HOST = "open.bigmodel.cn"; const GLM_PATH = "/api/coding/paas/v4/chat/completions"; const GLM_MODEL = "glm-5.3-flash"; +// T24 credit topup. No test-mode key yet (2026-09-03: user said "先做一个 +// mock,我明天再给你真实测试的 key") — see createCheckoutSession()'s doc +// comment for how the two paths split. +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || ""; +const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ""; +const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 8934}`; + const MIME = { ".html": "text/html", ".js": "text/javascript", @@ -117,6 +124,18 @@ function readJsonBody(req) { }); } +// Stripe's webhook signature is computed over the exact raw request bytes +// (T24) — JSON.parse-then-restringify would not reproduce the same bytes, +// so this can't reuse readJsonBody for that one route. +function readRawTextBody(req) { + return new Promise((resolve, reject) => { + let data = ""; + req.on("data", (chunk) => (data += chunk)); + req.on("end", () => resolve(data)); + req.on("error", reject); + }); +} + // Minimal admin auth (dev-plan.md T6): one shared password from .env, an // HttpOnly session cookie, no accounts/roles/DB — the account system in // MVCP M.1/M.10 supersedes this in period 3. Sessions are in-memory only, @@ -465,6 +484,97 @@ function glmChat(messages) { }); } +// ---- T24: Stripe topup, with a mock fallback until a real test-mode key +// exists (2026-09-03: user said "先做一个 mock,我明天再给你真实测试的 +// key") — createCheckoutSession() is the single seam both paths sit +// behind, matching T20/T25/T26's "one pluggable function" precedent. ---- +const pendingMockCheckouts = new Map(); // token -> {userId, tierKey, expiresAt} +const MOCK_CHECKOUT_TTL_MS = 30 * 60 * 1000; + +// Both the mock path (GET /api/credits/mock-checkout) and the real path +// (POST /api/credits/webhook) end up here — one place that actually moves +// money, so there's exactly one code path responsible for the +// ledger/idempotency bookkeeping (topup()'s paymentRef dedup, db.js). +function completeTopup({ userId, tierKey, paymentRef }) { + const tier = pricing.get(tierKey); + if (!tier || tier.kind !== "topup_tier" || !tier.isActive) { + throw new Error(`unknown or inactive topup tier: ${tierKey}`); + } + return creditLedger.topup({ userId, amount: tier.credits, reason: "topup", paymentRef }); +} + +// Real Stripe path: plain REST via global fetch, no stripe npm SDK — this +// project has no package.json/dependencies, and Stripe's Checkout Sessions +// API is documented as plain form-encoded REST (same "use fetch, not a new +// dependency" call as forwardToH3Assets in uploads.js). Untested against a +// real Stripe account as of this commit — no test-mode key exists yet, see +// dev-plan-4.md §1.2 item 4. +async function createRealStripeCheckoutSession({ userId, tierKey, tier }) { + const params = new URLSearchParams({ + mode: "payment", + "line_items[0][quantity]": "1", + "line_items[0][price_data][currency]": "usd", + "line_items[0][price_data][unit_amount]": String(tier.priceCents), + "line_items[0][price_data][product_data][name]": `${tier.credits} credits`, + success_url: `${APP_BASE_URL}/dance.html?topup=success`, + cancel_url: `${APP_BASE_URL}/dance.html?topup=cancelled`, + "metadata[userId]": userId, + "metadata[tierKey]": tierKey, + }); + const res = await fetch("https://api.stripe.com/v1/checkout/sessions", { + method: "POST", + headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}`, "Content-Type": "application/x-www-form-urlencoded" }, + body: params, + }); + const session = await res.json(); + if (!res.ok) throw new Error(session.error?.message || "Stripe checkout session creation failed"); + return session.url; +} + +// Mock path: no real payment happens, but the whole checkout -> redirect -> +// credited loop is real and testable today — T29's UI and topup()'s own +// dedup logic don't need to wait on a real Stripe key to be built and +// exercised end to end. +function createMockCheckoutUrl({ userId, tierKey }) { + const token = crypto.randomUUID(); + pendingMockCheckouts.set(token, { userId, tierKey, expiresAt: Date.now() + MOCK_CHECKOUT_TTL_MS }); + return `/api/credits/mock-checkout?token=${token}`; +} + +async function createCheckoutSession({ userId, tierKey }) { + const tier = pricing.get(tierKey); + if (!tier || tier.kind !== "topup_tier" || !tier.isActive) { + throw new Error(`unknown or inactive topup tier: ${tierKey}`); + } + if (!STRIPE_SECRET_KEY) return { url: createMockCheckoutUrl({ userId, tierKey }) }; + return { url: await createRealStripeCheckoutSession({ userId, tierKey, tier }) }; +} + +// Stripe's documented signature scheme (Stripe-Signature header: +// "t=,v1="): HMAC-SHA256 over "." +// with the webhook signing secret, compared in constant time. Hand-rolled +// rather than the stripe SDK for the same "no new dependency" reason as +// the rest of this integration. +function verifyStripeWebhookSignature(rawBody, signatureHeader, secret) { + if (!signatureHeader) return false; + const parts = Object.fromEntries( + signatureHeader + .split(",") + .map((kv) => kv.split("=")) + .filter((kv) => kv.length === 2) + ); + if (!parts.t || !parts.v1) return false; + const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"); + let expectedBuf, actualBuf; + try { + expectedBuf = Buffer.from(expected, "hex"); + actualBuf = Buffer.from(parts.v1, "hex"); + } catch { + return false; + } + return expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf); +} + // Distilled from docs/h3skill.md (MiniMax's own H3 prompt-writing methodology) down to the one // path that applies here: T2VA (no reference images — every chat prompt is plain text) on a clip // fixed at ~10s (H3_GEN_PARAMS.frames / FPS). At that length it's one continuous shot, never a @@ -1211,7 +1321,118 @@ http return; } res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); + // creditBalance riding along here (T29) rather than a dedicated + // /api/credits/balance endpoint — it's the same "logged-in user's own + // state" this route already exists to serve, and refreshAuthState() + // in live.html/dance.html (T22) already polls this after every login. + res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname, creditBalance: user.creditBalance })); + return; + } + + // T24: credit topup. Login + emailVerified matches M.10's "任何扣积分/ + // 写用户资产的接口" rule — a topup adds money rather than spending it, + // but still needs an accountable identity before a real charge runs. + if (pathname === "/api/credits/checkout" && req.method === "POST") { + const user = requireUserSession(req); + if (!user) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "login required" })); + return; + } + if (!user.emailVerified) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "email verification required before purchasing credits" })); + return; + } + readJsonBody(req) + .then(async (body) => { + try { + const session = await createCheckoutSession({ userId: user.id, tierKey: (body.tierKey || "").trim() }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(session)); + } catch (err) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + + // Dev-only completion of a mock checkout created above — simulates the + // browser landing back from Stripe's hosted page after a successful + // payment. Only ever reachable via a token this same process handed + // out (createMockCheckoutUrl), so there's nothing to authenticate here + // beyond "does this token exist and is it unused." + if (pathname === "/api/credits/mock-checkout" && req.method === "GET") { + const token = new URL(req.url, "http://x").searchParams.get("token") || ""; + const pending = pendingMockCheckouts.get(token); + pendingMockCheckouts.delete(token); // single-use, same spirit as T20's email-verify tokens + if (!pending || pending.expiresAt < Date.now()) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("invalid or expired mock checkout token"); + return; + } + try { + completeTopup({ userId: pending.userId, tierKey: pending.tierKey, paymentRef: `mock_${token}` }); + res.writeHead(302, { Location: "/dance.html?topup=success" }); + res.end(); + } catch (err) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end(err.message); + } + return; + } + + // Real Stripe webhook — only meaningful once a real + // STRIPE_WEBHOOK_SECRET is configured (tomorrow, per the user). + // Refuses anything it can't verify rather than trusting an + // unsigned/unverifiable request, even before a secret exists + // (dev-plan-4.md T24's explicit acceptance criterion). + if (pathname === "/api/credits/webhook" && req.method === "POST") { + readRawTextBody(req) + .then((rawBody) => { + if (!STRIPE_WEBHOOK_SECRET || !verifyStripeWebhookSignature(rawBody, req.headers["stripe-signature"], STRIPE_WEBHOOK_SECRET)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "signature verification failed" })); + return; + } + let event; + try { + event = JSON.parse(rawBody); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON" })); + return; + } + if (event.type === "checkout.session.completed") { + const session = event.data.object; + try { + // event.id, not session.id: Stripe's at-least-once + // redelivery resends the same event id for the same + // occurrence, which is exactly what topup()'s paymentRef + // dedup (db.js) keys on. + completeTopup({ userId: session.metadata.userId, tierKey: session.metadata.tierKey, paymentRef: event.id }); + } catch (err) { + // Already-applied duplicates are handled inside topup() + // itself (returns the existing row, doesn't throw) — a throw + // here means something genuinely went wrong (unknown tier, + // missing user), not a benign redelivery. + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + return; + } + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ received: true })); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "failed to read request body" })); + }); return; }