diff --git a/.changeset/secure-billing-state.md b/.changeset/secure-billing-state.md index 51473243..5e828de3 100644 --- a/.changeset/secure-billing-state.md +++ b/.changeset/secure-billing-state.md @@ -1,5 +1,5 @@ --- -"paykitjs": patch +"paykitjs": minor --- Harden browser return URLs and customer mutations, reject test clocks with live Stripe keys, make customer deletion and billing upserts race-safe, and enforce webhook claim ownership. The database migration deduplicates existing Stripe billing rows before adding unique indexes. diff --git a/.env.example b/.env.example index b0d20272..8a921ea8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # e2e TEST_DATABASE_URL=postgresql://localhost:5432/postgres +CF_TUNNEL_TOKEN= E2E_STRIPE_SK= E2E_STRIPE_WHSEC= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21402a50..ee7de4e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,14 @@ jobs: - name: Build run: pnpm build + env: + APP_URL: https://example.invalid + AUTH_DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci + BETTER_AUTH_SECRET: ci-build-placeholder-not-for-runtime-0000000000000000 + PAYKIT_DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci + RESEND_API_KEY: ci-build-placeholder + STRIPE_SECRET_KEY: ci-build-placeholder + STRIPE_WEBHOOK_SECRET: ci-build-placeholder unit: runs-on: ubuntu-latest diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9c3e7072..621888dd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -122,26 +122,9 @@ jobs: - name: Install workspace dependencies run: pnpm install --frozen-lockfile - - name: Validate Stripe E2E configuration - env: - CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_STRIPE }} - E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }} - E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }} - run: | - set -euo pipefail - for name in CF_TUNNEL_TOKEN E2E_STRIPE_SK E2E_STRIPE_WHSEC TEST_DATABASE_URL; do - if [ -z "${!name:-}" ]; then - echo "::error::Missing required configuration: $name" - exit 1 - fi - done - - name: Setup cloudflared uses: AnimMouse/setup-cloudflared@b80ad7aa7850e1ec9960d75831f32ac9855df988 # v2 - - name: Check cloudflared version - run: cloudflared --version - - name: Install Playwright Chromium run: pnpm --filter e2e exec playwright install --with-deps chromium @@ -150,48 +133,7 @@ jobs: CF_TUNNEL_TOKEN: ${{ secrets.CF_TUNNEL_TOKEN_STRIPE }} E2E_STRIPE_SK: ${{ secrets.E2E_STRIPE_SK }} E2E_STRIPE_WHSEC: ${{ secrets.E2E_STRIPE_WHSEC }} - run: | - set -euo pipefail - cloudflared tunnel --url http://127.0.0.1:4567 run --token "$CF_TUNNEL_TOKEN" > cloudflared.log 2>&1 & - cloudflared_pid=$! - - cleanup() { - if kill -0 "$cloudflared_pid" 2>/dev/null; then - kill "$cloudflared_pid" || true - wait "$cloudflared_pid" || true - fi - } - - trap cleanup EXIT - - readiness_timeout_s=30 - readiness_deadline=$((SECONDS + readiness_timeout_s)) - - while true; do - if ! kill -0 "$cloudflared_pid" 2>/dev/null; then - echo "::error::cloudflared exited before tests started" - if [ -f cloudflared.log ]; then - cat cloudflared.log - fi - exit 1 - fi - - if [ -f cloudflared.log ] && grep -Eq 'Registered tunnel|Connection [A-Za-z0-9]+ registered|INF.*Registered tunnel connection' cloudflared.log; then - break - fi - - if [ "$SECONDS" -ge "$readiness_deadline" ]; then - echo "::error::Timed out waiting for cloudflared readiness" - if [ -f cloudflared.log ]; then - cat cloudflared.log - fi - exit 1 - fi - - sleep 0.5 - done - - pnpm --filter e2e test:stripe + run: pnpm --filter e2e test:stripe - name: Upload cloudflared log if: failure() diff --git a/.gitignore b/.gitignore index 3bd001a1..bd514678 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ dist npm-debug.log* yarn-debug.log* yarn-error.log* +cloudflared.log # Misc .DS_Store diff --git a/e2e/cli/push.test.ts b/e2e/cli/push.test.ts index b2f13db8..6dd5ee3c 100644 --- a/e2e/cli/push.test.ts +++ b/e2e/cli/push.test.ts @@ -78,15 +78,27 @@ describe("paykitjs push", () => { .from(product) .orderBy(asc(product.id)); expect(dbRows).toEqual([ - { id: "free", name: "Free", group: "base", is_default: true, priceCurrency: null }, - { id: "pro", name: "Pro", group: "base", is_default: false, priceCurrency: "usd" }, + { + id: fixture.planIds.free, + name: "Free", + group: "base", + is_default: true, + priceCurrency: null, + }, + { + id: fixture.planIds.pro, + name: "Pro", + group: "base", + is_default: false, + priceCurrency: "usd", + }, ]); // Verify paid plan (pro) was synced to Stripe. const proRows = await ctx.database .select({ id: product.id, stripeProductId: product.stripeProductId }) .from(product) - .where(eq(product.id, "pro")) + .where(eq(product.id, fixture.planIds.pro)) .orderBy(desc(product.version)) .limit(1); const proProduct = proRows[0] as { id: string; stripeProductId: string | null } | undefined; @@ -132,7 +144,7 @@ describe("paykitjs push", () => { }); const results = await syncProducts(ctx); - const proResult = results.find((r) => r.id === "pro"); + const proResult = results.find((r) => r.id === fixture.planIds.pro); expect(proResult).toMatchObject({ action: "created", version: 2 }); const proRows = await ctx.database @@ -141,7 +153,7 @@ describe("paykitjs push", () => { stripePriceId: product.stripePriceId, }) .from(product) - .where(eq(product.id, "pro")) + .where(eq(product.id, fixture.planIds.pro)) .orderBy(desc(product.version)) .limit(1); const proProduct = proRows[0]; @@ -154,7 +166,7 @@ describe("paykitjs push", () => { expect(stripePrice.currency).toBe("eur"); const diffs = await dryRunSyncProducts(ctx); - expect(diffs.find((d) => d.id === "pro")?.action).toBe("unchanged"); + expect(diffs.find((d) => d.id === fixture.planIds.pro)?.action).toBe("unchanged"); } finally { await database.end(); } diff --git a/e2e/cli/setup.ts b/e2e/cli/setup.ts index dd98fa73..c1e645eb 100644 --- a/e2e/cli/setup.ts +++ b/e2e/cli/setup.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -16,6 +17,7 @@ export interface CliTestFixture { cwd: string; dbName: string; dbUrl: string; + planIds: { free: string; pro: string }; stripeClient: Stripe; cleanup: () => Promise; } @@ -33,8 +35,11 @@ export async function createCliFixture(_globalKey: string): Promise { - // Clean up Stripe products created by push + // Clean up only Stripe products tagged with this fixture's unique plan ID. try { - const products = await stripeClient.products.list({ limit: 100 }); - for (const product of products.data) { - const paykitId = product.metadata.paykit_product_id; - if (paykitId === "free" || paykitId === "pro") { - // Archive prices first - const prices = await stripeClient.prices.list({ product: product.id, limit: 100 }); - for (const price of prices.data) { - if (price.active) { - await stripeClient.prices.update(price.id, { active: false }); - } + for await (const stripeProduct of stripeClient.products.list({ limit: 100 })) { + if (stripeProduct.metadata.paykit_product_id !== planIds.pro) continue; + + for await (const price of stripeClient.prices.list({ + product: stripeProduct.id, + limit: 100, + })) { + if (price.active) { + await stripeClient.prices.update(price.id, { active: false }); } - await stripeClient.products.update(product.id, { active: false }); } + await stripeClient.products.update(stripeProduct.id, { active: false }); } } catch { // Best effort cleanup @@ -118,5 +122,5 @@ export async function createCliFixture(_globalKey: string): Promise { @@ -61,15 +60,19 @@ describe("cancel-end-of-cycle: pro → free + clock advance", () => { customerId, frozenTime: advanceTo, }); - await waitForWebhook({ - after: beforeAdvance, - database: t.database, - eventType: "subscription.deleted", - timeout: 30_000, - }); - // Poll until Free is active after the forwarded deletion event is processed + // Poll until webhook processing applies the scheduled plan transition. for (let i = 0; i < 60; i++) { + const failedWebhook = await t.database.query.webhookEvent.findFirst({ + where: and(eq(webhookEvent.status, "failed"), gt(webhookEvent.receivedAt, beforeAdvance)), + orderBy: (event, { desc: descending }) => [descending(event.receivedAt)], + }); + if (failedWebhook) { + throw new Error( + `Webhook ${failedWebhook.type} failed after clock advance: ${String(failedWebhook.error)}`, + ); + } + const rows = await t.database .select({ status: subscription.status }) .from(subscription) @@ -122,6 +125,16 @@ describe("cancel-end-of-cycle: pro → free + clock advance", () => { limit: 100, remaining: 100, }); + + const failedWebhook = await t.database.query.webhookEvent.findFirst({ + where: and(eq(webhookEvent.status, "failed"), gt(webhookEvent.receivedAt, beforeAdvance)), + orderBy: (event, { desc: descending }) => [descending(event.receivedAt)], + }); + if (failedWebhook) { + throw new Error( + `Webhook ${failedWebhook.type} failed after clock advance: ${String(failedWebhook.error)}`, + ); + } } catch (error) { await dumpStateOnFailure(t.database, t.dbPath); throw error; diff --git a/e2e/package.json b/e2e/package.json index 94cd39ff..5670a050 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "scripts": { - "test:stripe": "PROVIDER=stripe vitest run --project=core", - "test:stripe:watch": "PROVIDER=stripe vitest --project=core", + "test:stripe": "node ./scripts/run-stripe-e2e.mjs", + "test:stripe:watch": "node ./scripts/run-stripe-e2e.mjs --watch", "test:cli": "vitest run --project=cli", "test:cli:watch": "vitest --project=cli", "test:database": "vitest run --project=database", diff --git a/e2e/scripts/run-stripe-e2e.mjs b/e2e/scripts/run-stripe-e2e.mjs new file mode 100644 index 00000000..6b64ae28 --- /dev/null +++ b/e2e/scripts/run-stripe-e2e.mjs @@ -0,0 +1,261 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import net from "node:net"; +import { devNull } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Pool } from "pg"; + +import "../../scripts/load-root-env.js"; + +const e2eRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(e2eRoot, ".."); +const cloudflaredLogPath = path.join(repoRoot, "cloudflared.log"); +const hubPort = 4567; +const watch = process.argv.includes("--watch"); +const forwardedArguments = process.argv + .slice(2) + .filter((argument) => argument !== "--watch" && argument !== "--"); +const requiredEnvironment = [ + "CF_TUNNEL_TOKEN", + "E2E_STRIPE_SK", + "E2E_STRIPE_WHSEC", + "TEST_DATABASE_URL", +]; + +let cleanupPromise; +let receivedSignal; +let tunnelProcess; +let testProcess; + +function waitForExit(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + + return new Promise((resolve) => { + const cleanup = () => { + child.off("error", onError); + child.off("exit", onExit); + }; + const onError = (error) => { + cleanup(); + resolve({ code: null, error, signal: null }); + }; + const onExit = (code, signal) => { + cleanup(); + resolve({ code, error: null, signal }); + }; + + child.once("error", onError); + child.once("exit", onExit); + }); +} + +async function terminate(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + + child.kill("SIGTERM"); + let graceTimer; + await Promise.race([ + waitForExit(child), + new Promise((resolve) => { + graceTimer = setTimeout(resolve, 5_000); + }), + ]).finally(() => clearTimeout(graceTimer)); + + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await waitForExit(child); + } +} + +function stopChildren() { + cleanupPromise ??= (async () => { + await terminate(testProcess); + await terminate(tunnelProcess); + })(); + return cleanupPromise; +} + +function validateEnvironment() { + const missing = requiredEnvironment.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error(`Missing required E2E configuration: ${missing.join(", ")}`); + } +} + +function validateCloudflared() { + const result = spawnSync("cloudflared", ["--version"], { encoding: "utf8" }); + if (result.error || result.status !== 0) { + throw new Error("cloudflared is required to run Stripe E2E tests"); + } +} + +async function validateDatabase() { + const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL }); + try { + await pool.query("SELECT 1"); + } finally { + await pool.end(); + } +} + +async function validateHubPort() { + const server = net.createServer(); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(hubPort, "127.0.0.1", resolve); + }); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") { + throw new Error( + `Hub port ${String(hubPort)} already in use. Kill any stale webhook server before running tests.`, + { cause: error }, + ); + } + throw error; + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function startTunnel() { + const log = createWriteStream(cloudflaredLogPath, { flags: "w" }); + const child = spawn( + "cloudflared", + ["tunnel", "--config", devNull, "--url", `http://127.0.0.1:${String(hubPort)}`, "run"], + { + env: { ...process.env, TUNNEL_TOKEN: process.env.CF_TUNNEL_TOKEN }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + child.stdout.pipe(log, { end: false }); + child.stderr.pipe(log, { end: false }); + child.once("close", () => log.end()); + return child; +} + +async function waitForTunnel(child) { + const readyPattern = + /Registered tunnel|Connection [A-Za-z0-9]+ registered|Registered tunnel connection/; + + await new Promise((resolve, reject) => { + let output = ""; + const failure = (message) => + new Error(`${message}\n\ncloudflared output:\n${output.trim() || "(no output)"}`); + const timeout = setTimeout(() => { + cleanup(); + reject(failure("Timed out waiting for cloudflared readiness")); + }, 30_000); + + const onData = (chunk) => { + output = `${output}${chunk.toString()}`.slice(-8_192); + if (readyPattern.test(output)) { + cleanup(); + resolve(); + } + }; + const onExit = (code, signal) => { + cleanup(); + reject( + failure( + `cloudflared exited before tests started (code=${String(code)}, signal=${String(signal)})`, + ), + ); + }; + const onError = (error) => { + cleanup(); + reject(failure(`cloudflared failed to start: ${error.message}`)); + }; + const cleanup = () => { + clearTimeout(timeout); + child.off("error", onError); + child.stdout.off("data", onData); + child.stderr.off("data", onData); + child.off("exit", onExit); + }; + + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.once("error", onError); + child.once("exit", onExit); + }); +} + +function startTests() { + const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const vitestArguments = [ + "exec", + "vitest", + ...(watch ? [] : ["run"]), + "--project=core", + ...forwardedArguments, + ]; + return spawn(pnpm, vitestArguments, { + cwd: e2eRoot, + env: { ...process.env, PROVIDER: "stripe" }, + stdio: "inherit", + }); +} + +async function run() { + validateEnvironment(); + validateCloudflared(); + await validateDatabase(); + if (receivedSignal) return; + await validateHubPort(); + if (receivedSignal) return; + + tunnelProcess = startTunnel(); + await waitForTunnel(tunnelProcess); + console.log(`Cloudflare Tunnel ready. Logs: ${cloudflaredLogPath}`); + + testProcess = startTests(); + const outcome = await Promise.race([ + waitForExit(testProcess).then((result) => ({ source: "tests", ...result })), + waitForExit(tunnelProcess).then((result) => ({ source: "tunnel", ...result })), + ]); + if (receivedSignal) return; + + if (outcome.source === "tunnel") { + const failure = outcome.error + ? `cloudflared failed: ${outcome.error.message}` + : `cloudflared exited during tests (code=${String(outcome.code)}, signal=${String(outcome.signal)})`; + throw new Error(failure); + } + if (outcome.error) { + throw new Error(`Failed to start Stripe E2E tests: ${outcome.error.message}`, { + cause: outcome.error, + }); + } + if (outcome.code !== 0) { + process.exitCode = outcome.code ?? 1; + } +} + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + receivedSignal = signal; + const signalExitCode = signal === "SIGINT" ? 130 : 143; + if (process.exitCode === undefined || process.exitCode === 0) { + process.exitCode = signalExitCode; + } + void stopChildren().finally(() => process.exit(process.exitCode ?? signalExitCode)); + }); +} + +try { + await run(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + if (!receivedSignal) { + process.exitCode = 1; + } +} finally { + await stopChildren(); +} diff --git a/e2e/test-utils/env.ts b/e2e/test-utils/env.ts index 73d45dee..53416245 100644 --- a/e2e/test-utils/env.ts +++ b/e2e/test-utils/env.ts @@ -6,7 +6,7 @@ import "../../scripts/load-root-env.js"; export const env = createEnv({ server: { PROVIDER: z.enum(["stripe"]).default("stripe"), - TEST_DATABASE_URL: z.string().default("postgresql://localhost:5432/postgres"), + TEST_DATABASE_URL: z.string().min(1), // Stripe E2E_STRIPE_SK: z.string().optional(),