292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
/**
|
|
* H-33 Stripe smoke — programmatic end-to-end.
|
|
*
|
|
* Covers every item in `docs/OPS/stripe-smoke.md` without needing the
|
|
* `stripe listen` / long-lived-sidecar dance:
|
|
*
|
|
* 1. `checkout.session.completed` → tier updated + customer ID stored
|
|
* 2. `customer.subscription.updated` → tier reflects new price
|
|
* 3. `customer.subscription.deleted` → tier reverts to FREE
|
|
* 4. Duplicate event → .stripe-processed-events.json dedup
|
|
* 5. `/api/stripe/create-checkout-session` returns a real Stripe Checkout URL
|
|
* 6. `/api/stripe/create-portal-session` returns a real Stripe portal URL
|
|
* 7. Signature validation → invalid sig → 400 INVALID_SIGNATURE
|
|
*
|
|
* Signs events using `Stripe.webhooks.generateTestHeaderString` so we exercise
|
|
* the real signature-validation path without running `stripe listen`.
|
|
*
|
|
* ## How to run
|
|
*
|
|
* ```sh
|
|
* STRIPE_SECRET_KEY=sk_test_... \
|
|
* STRIPE_PRICE_TEAMS=price_... \
|
|
* WAGGLE_STRIPE_SMOKE=1 \
|
|
* npx vitest run packages/server/tests/stripe/smoke-e2e.test.ts
|
|
* ```
|
|
*
|
|
* Without `WAGGLE_STRIPE_SMOKE=1` the suite self-skips so CI stays green on
|
|
* dev machines that don't have a Stripe sandbox wired up.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import type { FastifyInstance } from 'fastify';
|
|
|
|
// ── Gate: self-skip when the smoke env isn't configured ────────
|
|
|
|
const ENABLED = process.env.WAGGLE_STRIPE_SMOKE === '1' || process.env.WAGGLE_STRIPE_SMOKE === 'true';
|
|
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY ?? '';
|
|
const PRICE_TEAMS = process.env.STRIPE_PRICE_TEAMS ?? '';
|
|
|
|
// Generate a webhook secret fresh for this run — the sidecar would read
|
|
// whsec_... from `stripe listen`, but since we sign events ourselves the
|
|
// only requirement is that both sides share the same secret.
|
|
const WEBHOOK_SECRET = `whsec_${'smoke'.padEnd(32, '0')}`;
|
|
process.env.STRIPE_WEBHOOK_SECRET = WEBHOOK_SECRET;
|
|
|
|
const SHOULD_RUN =
|
|
ENABLED &&
|
|
STRIPE_KEY.startsWith('sk_test_') &&
|
|
PRICE_TEAMS.startsWith('price_');
|
|
|
|
// ── Skip block that emits a visible reason in test output ──────
|
|
|
|
if (!SHOULD_RUN) {
|
|
describe.skip('Stripe smoke E2E (H-33)', () => {
|
|
it('skipped — set WAGGLE_STRIPE_SMOKE=1 + STRIPE_* env vars to run', () => {
|
|
// Intentional no-op.
|
|
});
|
|
});
|
|
} else {
|
|
describe('Stripe smoke E2E (H-33)', () => {
|
|
let server: FastifyInstance;
|
|
let tmpDir: string;
|
|
let stripe: import('stripe').default;
|
|
// Record structured checklist output for later inclusion in ops notes.
|
|
const checklist: Record<string, { passed: boolean; detail: string }> = {};
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-stripe-smoke-'));
|
|
|
|
// Seed config.json at FREE so the tier flip is visible in step 1.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'config.json'),
|
|
JSON.stringify({ tier: 'FREE' }, null, 2),
|
|
);
|
|
|
|
// Lazy imports so env stubs above land before @waggle/shared module-loads.
|
|
const [{ buildLocalServer }, stripeModule, { authInject }] = await Promise.all([
|
|
import('../../src/local/index.js'),
|
|
import('stripe'),
|
|
import('../test-utils.js'),
|
|
]);
|
|
|
|
const Stripe = (stripeModule as unknown as { default: typeof import('stripe').default }).default;
|
|
stripe = new Stripe(STRIPE_KEY, { apiVersion: '2025-03-31.basil' });
|
|
|
|
server = await buildLocalServer({ dataDir: tmpDir });
|
|
// Attach the helper so individual tests don't re-import.
|
|
(server as unknown as { _authInject: typeof authInject })._authInject = authInject;
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
try { await server?.close(); } catch { /* best effort */ }
|
|
await new Promise(r => setTimeout(r, 150));
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* EBUSY on win32 */ }
|
|
|
|
// Print the structured checklist. Surfaces a CI-friendly summary even
|
|
// when the UI rolls up per-assertion green ticks.
|
|
|
|
console.log('\n=== H-33 Stripe smoke checklist ===\n' +
|
|
Object.entries(checklist)
|
|
.map(([k, v]) => ` [${v.passed ? 'x' : ' '}] ${k} — ${v.detail}`)
|
|
.join('\n') + '\n');
|
|
});
|
|
|
|
function readConfig(): Record<string, unknown> {
|
|
const raw = fs.readFileSync(path.join(tmpDir, 'config.json'), 'utf-8');
|
|
return JSON.parse(raw) as Record<string, unknown>;
|
|
}
|
|
|
|
function postWebhook(rawEvent: unknown, signatureOverride?: string): ReturnType<FastifyInstance['inject']> {
|
|
const payload = JSON.stringify(rawEvent);
|
|
const sig = signatureOverride ?? stripe.webhooks.generateTestHeaderString({
|
|
payload,
|
|
secret: WEBHOOK_SECRET,
|
|
});
|
|
return server.inject({
|
|
method: 'POST',
|
|
url: '/api/stripe/webhook',
|
|
payload,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'stripe-signature': sig,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Build a minimal-but-valid Stripe event shape. Our webhook doesn't
|
|
// validate the whole schema — only the fields it consumes — so this
|
|
// saves us from round-tripping through the real API.
|
|
interface MinimalEvent {
|
|
id: string;
|
|
type: string;
|
|
data: { object: Record<string, unknown> };
|
|
}
|
|
function makeEvent(id: string, type: string, object: Record<string, unknown>): MinimalEvent {
|
|
return { id, type, data: { object } };
|
|
}
|
|
|
|
it('[1] checkout.session.completed → tier=TEAMS + stripe_customer_id set', async () => {
|
|
const event = makeEvent('evt_smoke_checkout_1', 'checkout.session.completed', {
|
|
metadata: { tier: 'TEAMS' },
|
|
customer: 'cus_smoke_1',
|
|
});
|
|
const res = await postWebhook(event);
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const cfg = readConfig();
|
|
expect(cfg.tier).toBe('TEAMS');
|
|
expect(cfg.stripe_customer_id).toBe('cus_smoke_1');
|
|
checklist['1. checkout.session.completed'] = {
|
|
passed: true,
|
|
detail: `tier=TEAMS + customer=cus_smoke_1`,
|
|
};
|
|
});
|
|
|
|
it('[2] customer.subscription.updated → tier flips to TEAMS for PRICE_TEAMS', async () => {
|
|
const event = makeEvent('evt_smoke_sub_upd_1', 'customer.subscription.updated', {
|
|
customer: 'cus_smoke_1',
|
|
items: { data: [{ price: { id: PRICE_TEAMS } }] },
|
|
});
|
|
const res = await postWebhook(event);
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const cfg = readConfig();
|
|
expect(cfg.tier).toBe('TEAMS');
|
|
checklist['2. customer.subscription.updated'] = {
|
|
passed: true,
|
|
detail: `PRICE_TEAMS (${PRICE_TEAMS.slice(0, 16)}…) → tier=TEAMS`,
|
|
};
|
|
});
|
|
|
|
it('[3] customer.subscription.deleted → tier reverts to FREE', async () => {
|
|
const event = makeEvent('evt_smoke_sub_del_1', 'customer.subscription.deleted', {
|
|
customer: 'cus_smoke_1',
|
|
});
|
|
const res = await postWebhook(event);
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const cfg = readConfig();
|
|
expect(cfg.tier).toBe('FREE');
|
|
checklist['3. customer.subscription.deleted'] = {
|
|
passed: true,
|
|
detail: 'tier reverted to FREE',
|
|
};
|
|
});
|
|
|
|
it('[4] duplicate event ID is deduped — config unchanged', async () => {
|
|
// Flip back to PRO, then re-fire the same event id. Second call must
|
|
// be a no-op according to .stripe-processed-events.json.
|
|
await postWebhook(makeEvent('evt_smoke_dedup_pre', 'checkout.session.completed', {
|
|
metadata: { tier: 'TEAMS' },
|
|
customer: 'cus_smoke_1',
|
|
}));
|
|
expect(readConfig().tier).toBe('TEAMS');
|
|
|
|
// Now mutate state would-be: send a dedup-checking event, then resend.
|
|
const event = makeEvent('evt_smoke_checkout_1', 'checkout.session.completed', {
|
|
metadata: { tier: 'FREE' }, // would downgrade if re-processed
|
|
customer: 'cus_smoke_1',
|
|
});
|
|
const res = await postWebhook(event);
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body) as { received: boolean; duplicate?: boolean };
|
|
expect(body.duplicate).toBe(true);
|
|
|
|
// Tier unchanged — the dedup guard kept us on TEAMS.
|
|
expect(readConfig().tier).toBe('TEAMS');
|
|
checklist['4. duplicate event dedup'] = {
|
|
passed: true,
|
|
detail: 'resending evt_smoke_checkout_1 left tier on TEAMS (duplicate skipped)',
|
|
};
|
|
});
|
|
|
|
it('[5] invalid signature → 400 INVALID_SIGNATURE', async () => {
|
|
const event = makeEvent('evt_smoke_badsig', 'checkout.session.completed', {
|
|
metadata: { tier: 'TEAMS' },
|
|
});
|
|
const res = await postWebhook(event, 't=0,v1=bogus');
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body) as { error: string };
|
|
expect(body.error).toBe('INVALID_SIGNATURE');
|
|
checklist['5. signature validation'] = {
|
|
passed: true,
|
|
detail: 'bogus signature → 400 INVALID_SIGNATURE',
|
|
};
|
|
});
|
|
|
|
it('[6] POST /api/stripe/create-checkout-session returns a real checkout URL', async () => {
|
|
const authInject = (server as unknown as { _authInject: typeof import('../test-utils.js').authInject })._authInject;
|
|
const res = await server.inject(authInject(server, {
|
|
method: 'POST',
|
|
url: '/api/stripe/create-checkout-session',
|
|
headers: { 'content-type': 'application/json' },
|
|
payload: { tier: 'TEAMS' },
|
|
}));
|
|
|
|
// TEAMS is the only paid checkout tier (PRO removed). The route needs
|
|
// STRIPE_PRICE_TEAMS to be configured.
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body) as { url: string };
|
|
expect(body.url).toMatch(/^https:\/\/checkout\.stripe\.com\//);
|
|
checklist['6. create-checkout-session'] = {
|
|
passed: true,
|
|
detail: `URL = ${body.url.slice(0, 48)}…`,
|
|
};
|
|
}, 30_000);
|
|
|
|
it('[7] POST /api/stripe/create-portal-session returns a real portal URL', async () => {
|
|
// Create a real test-mode customer in the sandbox so the portal call
|
|
// has something valid to target. Steps 1-4 wrote cus_smoke_1 into
|
|
// config.json — that ID doesn't exist in Stripe, so we override it.
|
|
const customer = await stripe.customers.create({
|
|
description: 'Waggle H-33 smoke — ephemeral',
|
|
metadata: { smoke_run: new Date().toISOString() },
|
|
});
|
|
|
|
// Update config.json to point at the real customer. The portal is now
|
|
// gated at FREE (any authenticated user) so the tier value is immaterial;
|
|
// use TEAMS as a representative paid state.
|
|
const cfgPath = path.join(tmpDir, 'config.json');
|
|
const current = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
|
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
...current,
|
|
tier: 'TEAMS',
|
|
stripe_customer_id: customer.id,
|
|
}, null, 2));
|
|
|
|
const authInject = (server as unknown as { _authInject: typeof import('../test-utils.js').authInject })._authInject;
|
|
const res = await server.inject(authInject(server, {
|
|
method: 'POST',
|
|
url: '/api/stripe/create-portal-session',
|
|
headers: { 'content-type': 'application/json' },
|
|
payload: {},
|
|
}));
|
|
|
|
// Clean up the ephemeral customer so we don't accumulate fixtures.
|
|
try { await stripe.customers.del(customer.id); } catch { /* best effort */ }
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body) as { url: string };
|
|
expect(body.url).toMatch(/^https:\/\/billing\.stripe\.com\//);
|
|
checklist['7. create-portal-session'] = {
|
|
passed: true,
|
|
detail: `customer=${customer.id} · URL = ${body.url.slice(0, 48)}…`,
|
|
};
|
|
}, 30_000);
|
|
});
|
|
}
|