credits.test.js 8.47 KB
// 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();
  }
});