This commit is contained in:
160
packages/server/tests/stripe/checkout.test.ts
Normal file
160
packages/server/tests/stripe/checkout.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Stripe Checkout — billingPeriod-aware price resolution (FINDING R1-003).
|
||||
*
|
||||
* The checkout route must honour the requested billingPeriod when picking the
|
||||
* Stripe price: 'annual' resolves the annual price, 'monthly' (and omitted)
|
||||
* resolves the monthly price with a legacy single-var fallback. ANNUAL fails
|
||||
* closed (F9): it never falls back to a monthly-priced var, returning
|
||||
* NO_PRICE_CONFIGURED rather than silently charging the monthly price.
|
||||
*
|
||||
* We mock getStripe (mirroring webhook.test.ts's getStripe / process.env
|
||||
* mocking style) with a fake Stripe whose checkout.sessions.create records the
|
||||
* price it was handed, and keep the REAL priceIdForTier so the env-driven
|
||||
* resolution logic is exercised end-to-end. Price env vars are cleared between
|
||||
* cases so tests stay order-independent.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
// Records the price passed to the most recent sessions.create call.
|
||||
let lastPriceArg: string | null = null;
|
||||
|
||||
// Fake Stripe — only the surface checkout.ts touches.
|
||||
const fakeStripe = {
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: vi.fn(async (params: { line_items: Array<{ price: string }> }) => {
|
||||
lastPriceArg = params.line_items[0]?.price ?? null;
|
||||
return { url: 'https://checkout.stripe.test/session_123' };
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the stripe barrel: override getStripe, keep the real priceIdForTier so
|
||||
// the env-var resolution contract is genuinely under test.
|
||||
vi.mock('../../src/stripe/index.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/stripe/index.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getStripe: () => fakeStripe,
|
||||
};
|
||||
});
|
||||
|
||||
import { checkoutRoutes } from '../../src/stripe/checkout.js';
|
||||
|
||||
const PRICE_ENV_KEYS = [
|
||||
'STRIPE_PRICE_BASIC',
|
||||
'STRIPE_PRICE_PRO',
|
||||
'STRIPE_PRICE_PRO_MONTHLY',
|
||||
'STRIPE_PRICE_PRO_ANNUAL',
|
||||
'STRIPE_PRICE_TEAMS',
|
||||
'STRIPE_PRICE_TEAMS_MONTHLY',
|
||||
'STRIPE_PRICE_TEAMS_ANNUAL',
|
||||
] as const;
|
||||
|
||||
describe('Stripe Checkout — billingPeriod-aware price resolution', () => {
|
||||
let server: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const k of PRICE_ENV_KEYS) delete process.env[k];
|
||||
lastPriceArg = null;
|
||||
fakeStripe.checkout.sessions.create.mockClear();
|
||||
|
||||
server = Fastify();
|
||||
await server.register(checkoutRoutes);
|
||||
await server.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const k of PRICE_ENV_KEYS) delete process.env[k];
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function postCheckout(body: Record<string, unknown>) {
|
||||
return server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/create-checkout-session',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: body,
|
||||
});
|
||||
}
|
||||
|
||||
it('annual billingPeriod uses the annual price (price_pa)', async () => {
|
||||
process.env['STRIPE_PRICE_TEAMS_MONTHLY'] = 'price_pm';
|
||||
process.env['STRIPE_PRICE_TEAMS_ANNUAL'] = 'price_pa';
|
||||
|
||||
const res = await postCheckout({ tier: 'TEAMS', billingPeriod: 'annual' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().url).toBe('https://checkout.stripe.test/session_123');
|
||||
expect(fakeStripe.checkout.sessions.create).toHaveBeenCalledOnce();
|
||||
expect(lastPriceArg).toBe('price_pa');
|
||||
});
|
||||
|
||||
it('monthly billingPeriod uses the monthly price (price_pm)', async () => {
|
||||
process.env['STRIPE_PRICE_TEAMS_MONTHLY'] = 'price_pm';
|
||||
process.env['STRIPE_PRICE_TEAMS_ANNUAL'] = 'price_pa';
|
||||
|
||||
const res = await postCheckout({ tier: 'TEAMS', billingPeriod: 'monthly' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(lastPriceArg).toBe('price_pm');
|
||||
});
|
||||
|
||||
it('omitted billingPeriod defaults to the monthly price (price_pm)', async () => {
|
||||
process.env['STRIPE_PRICE_TEAMS_MONTHLY'] = 'price_pm';
|
||||
process.env['STRIPE_PRICE_TEAMS_ANNUAL'] = 'price_pa';
|
||||
|
||||
const res = await postCheckout({ tier: 'TEAMS' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(lastPriceArg).toBe('price_pm');
|
||||
});
|
||||
|
||||
it('monthly falls back to the legacy single-var price when no 4-var contract is set', async () => {
|
||||
process.env['STRIPE_PRICE_TEAMS'] = 'price_legacy';
|
||||
|
||||
const res = await postCheckout({ tier: 'TEAMS', billingPeriod: 'monthly' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(lastPriceArg).toBe('price_legacy');
|
||||
});
|
||||
|
||||
it('annual FAILS CLOSED when only a (monthly) legacy single-var is set — never silently charges monthly (F9)', async () => {
|
||||
// The legacy STRIPE_PRICE_TEAMS is a monthly price; an annual selection must not
|
||||
// resolve to it (would charge monthly while the UI shows the annual price).
|
||||
process.env['STRIPE_PRICE_TEAMS'] = 'price_legacy';
|
||||
|
||||
const res = await postCheckout({ tier: 'TEAMS', billingPeriod: 'annual' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe('NO_PRICE_CONFIGURED');
|
||||
expect(fakeStripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 NO_PRICE_CONFIGURED for an annual selection when nothing is configured', async () => {
|
||||
// The monthly path can fall back to the legacy single-var / TIER_CAPABILITIES
|
||||
// default (STRIPE_PRICE_TEAMS is present in .env at module load), so the
|
||||
// deterministic "no price" case is the annual one: annual fails closed (F9)
|
||||
// and never falls back, so with nothing configured it returns null → 400.
|
||||
const res = await postCheckout({ tier: 'TEAMS', billingPeriod: 'annual' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe('NO_PRICE_CONFIGURED');
|
||||
expect(fakeStripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-TEAMS tier (legacy PRO) with 400 INVALID_TIER — TEAMS is the only paid checkout (PRO removed)', async () => {
|
||||
// Even with a price configured, a PRO checkout must be refused before price
|
||||
// resolution — Solo is free, Team is the only Stripe tier.
|
||||
process.env['STRIPE_PRICE_TEAMS_MONTHLY'] = 'price_pm';
|
||||
|
||||
const res = await postCheckout({ tier: 'PRO', billingPeriod: 'monthly' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe('INVALID_TIER');
|
||||
expect(fakeStripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
291
packages/server/tests/stripe/smoke-e2e.test.ts
Normal file
291
packages/server/tests/stripe/smoke-e2e.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
42
packages/server/tests/stripe/status.test.ts
Normal file
42
packages/server/tests/stripe/status.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* GET /api/stripe/status — the secret-free "is checkout wired" probe backing the
|
||||
* F8 honest disabled state. With no STRIPE_SECRET_KEY (the default test env),
|
||||
* getStripe() returns null, so the route reports { configured: false } and the
|
||||
* billing UI can disable the upgrade CTAs pre-click instead of 503-ing after one.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { statusRoutes } from '../../src/stripe/index.js';
|
||||
|
||||
describe('GET /api/stripe/status', () => {
|
||||
let server: FastifyInstance;
|
||||
const hadKey = process.env['STRIPE_SECRET_KEY'];
|
||||
|
||||
beforeEach(async () => {
|
||||
delete process.env['STRIPE_SECRET_KEY'];
|
||||
server = Fastify();
|
||||
await server.register(statusRoutes);
|
||||
await server.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (hadKey !== undefined) process.env['STRIPE_SECRET_KEY'] = hadKey;
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('reports configured:false when Stripe is not wired (no secret key)', async () => {
|
||||
const res = await server.inject({ method: 'GET', url: '/api/stripe/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ configured: false });
|
||||
});
|
||||
|
||||
it('reports configured:true when the Stripe SDK has a secret key', async () => {
|
||||
process.env['STRIPE_SECRET_KEY'] = 'sk_test_status_probe';
|
||||
|
||||
const res = await server.inject({ method: 'GET', url: '/api/stripe/status' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ configured: true });
|
||||
});
|
||||
});
|
||||
150
packages/server/tests/stripe/sync.test.ts
Normal file
150
packages/server/tests/stripe/sync.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
// ── Mock the Stripe SDK singleton ────────────────────────────────────
|
||||
// We control checkout.sessions.retrieve per-test via a settable holder.
|
||||
// tierFromPriceId stays REAL (we only override getStripe) so the
|
||||
// price-env resolution path is exercised end-to-end.
|
||||
let nextSession: unknown = null;
|
||||
|
||||
vi.mock('../../src/stripe/index.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/stripe/index.js')>(
|
||||
'../../src/stripe/index.js',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getStripe: () => ({
|
||||
checkout: {
|
||||
sessions: {
|
||||
retrieve: async () => nextSession,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Imported AFTER the mock is declared so the route picks up mocked getStripe.
|
||||
const { syncRoutes } = await import('../../src/stripe/sync.js');
|
||||
|
||||
function buildServer(dataDir: string): FastifyInstance {
|
||||
const server = Fastify();
|
||||
server.decorate('localConfig', { dataDir });
|
||||
server.register(syncRoutes);
|
||||
return server;
|
||||
}
|
||||
|
||||
function readTier(dataDir: string): string | undefined {
|
||||
const configPath = path.join(dataDir, 'config.json');
|
||||
if (!fs.existsSync(configPath)) return undefined;
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
return raw.tier as string | undefined;
|
||||
}
|
||||
|
||||
describe('POST /api/stripe/sync — payment gate (R1-002)', () => {
|
||||
let tmpDir: string;
|
||||
let server: FastifyInstance;
|
||||
|
||||
const PRICE_ENV_KEYS = ['STRIPE_PRICE_PRO'] as const;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-sync-test-'));
|
||||
nextSession = null;
|
||||
for (const k of PRICE_ENV_KEYS) delete process.env[k];
|
||||
process.env['STRIPE_SECRET_KEY'] = 'sk_test_dummy'; // not used (getStripe mocked) but keeps intent clear
|
||||
server = buildServer(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
for (const k of PRICE_ENV_KEYS) delete process.env[k];
|
||||
delete process.env['STRIPE_SECRET_KEY'];
|
||||
});
|
||||
|
||||
// (a) UNPAID session → 402, tier NOT persisted.
|
||||
it('rejects an unpaid session with 402 and does not change the tier', async () => {
|
||||
nextSession = {
|
||||
status: 'open',
|
||||
payment_status: 'unpaid',
|
||||
customer: 'cus_unpaid',
|
||||
metadata: { tier: 'TEAMS' },
|
||||
};
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/sync',
|
||||
payload: { sessionId: 'cs_test_unpaid' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(402);
|
||||
expect(res.json()).toMatchObject({ error: 'PAYMENT_NOT_COMPLETED' });
|
||||
// config.json must NOT have been written with a paid tier.
|
||||
expect(readTier(tmpDir)).toBeUndefined();
|
||||
});
|
||||
|
||||
// Defensive: a 'complete' session that somehow is still 'unpaid' is also gated.
|
||||
it('rejects a complete-but-unpaid session with 402 and does not change the tier', async () => {
|
||||
nextSession = {
|
||||
status: 'complete',
|
||||
payment_status: 'unpaid',
|
||||
customer: 'cus_weird',
|
||||
metadata: { tier: 'TEAMS' },
|
||||
};
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/sync',
|
||||
payload: { sessionId: 'cs_test_weird' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(402);
|
||||
expect(readTier(tmpDir)).toBeUndefined();
|
||||
});
|
||||
|
||||
// (b) Paid session with a legacy PRO subscription price → 200. Post-collapse a
|
||||
// legacy PRO price resolves to FREE (Solo), so a legacy PRO subscriber lands on
|
||||
// Solo rather than being locked out (decision #5).
|
||||
it('accepts a paid session and maps a legacy PRO subscription price to FREE (Solo)', async () => {
|
||||
process.env['STRIPE_PRICE_PRO'] = 'price_x';
|
||||
nextSession = {
|
||||
status: 'complete',
|
||||
payment_status: 'paid',
|
||||
customer: 'cus_paid',
|
||||
subscription: { items: { data: [{ price: { id: 'price_x' } }] } },
|
||||
};
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/sync',
|
||||
payload: { sessionId: 'cs_test_paid' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ tier: 'FREE', customerId: 'cus_paid' });
|
||||
expect(readTier(tmpDir)).toBe('FREE');
|
||||
});
|
||||
|
||||
// (c) Promo / 100%-off session → no_payment_required is treated as paid → 200.
|
||||
// A legacy PRO metadata tier collapses to FREE via parseTier.
|
||||
it('accepts a no_payment_required (promo) session and maps a legacy PRO metadata tier to FREE', async () => {
|
||||
nextSession = {
|
||||
status: 'complete',
|
||||
payment_status: 'no_payment_required',
|
||||
customer: 'cus_promo',
|
||||
metadata: { tier: 'PRO' },
|
||||
};
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/sync',
|
||||
payload: { sessionId: 'cs_test_promo' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ tier: 'FREE' });
|
||||
expect(readTier(tmpDir)).toBe('FREE');
|
||||
});
|
||||
});
|
||||
434
packages/server/tests/stripe/webhook.test.ts
Normal file
434
packages/server/tests/stripe/webhook.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import { updateUserTier } from '../../src/stripe/webhook.js';
|
||||
import { tierFromPriceId } from '../../src/stripe/index.js';
|
||||
import { securityMiddleware } from '../../src/local/security-middleware.js';
|
||||
import { parseTier } from '@waggle/shared';
|
||||
|
||||
// Mock only getStripe; keep the real tierFromPriceId so the 17 existing tests
|
||||
// (which import the genuine env-driven resolver) stay green.
|
||||
vi.mock('../../src/stripe/index.js', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../../src/stripe/index.js')>();
|
||||
return { ...actual, getStripe: () => fakeStripe };
|
||||
});
|
||||
|
||||
// Fake Stripe whose webhooks.constructEvent returns whatever event the test
|
||||
// queued. The webhook handler never inspects the signature beyond calling this.
|
||||
let nextEvent: unknown = null;
|
||||
const fakeStripe = {
|
||||
webhooks: {
|
||||
constructEvent: () => nextEvent,
|
||||
},
|
||||
} as unknown as import('stripe').default;
|
||||
|
||||
describe('Stripe Webhook — tier update logic', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-stripe-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('updateUserTier', () => {
|
||||
it('creates config.json and sets tier to TEAMS on checkout complete', () => {
|
||||
updateUserTier(tmpDir, 'TEAMS');
|
||||
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
expect(raw.tier).toBe('TEAMS');
|
||||
});
|
||||
|
||||
it('updates existing config.json without losing other fields', () => {
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ theme: 'dark', tier: 'FREE' }));
|
||||
|
||||
updateUserTier(tmpDir, 'TEAMS');
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
expect(raw.tier).toBe('TEAMS');
|
||||
expect(raw.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('downgrades tier to FREE on subscription deleted', () => {
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ tier: 'TEAMS' }));
|
||||
|
||||
updateUserTier(tmpDir, 'FREE');
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
expect(raw.tier).toBe('FREE');
|
||||
});
|
||||
|
||||
it('handles missing config.json gracefully', () => {
|
||||
// No config.json exists — should create one
|
||||
expect(() => updateUserTier(tmpDir, 'ENTERPRISE')).not.toThrow();
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(tmpDir, 'config.json'), 'utf-8'));
|
||||
expect(raw.tier).toBe('ENTERPRISE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tierFromPriceId', () => {
|
||||
// All Stripe price env vars we touch. Cleared before/after each test so
|
||||
// tests are order-independent and don't leak into one another.
|
||||
const PRICE_ENV_KEYS = [
|
||||
'STRIPE_PRICE_BASIC',
|
||||
'STRIPE_PRICE_PRO',
|
||||
'STRIPE_PRICE_PRO_MONTHLY',
|
||||
'STRIPE_PRICE_PRO_ANNUAL',
|
||||
'STRIPE_PRICE_TEAMS',
|
||||
'STRIPE_PRICE_TEAMS_MONTHLY',
|
||||
'STRIPE_PRICE_TEAMS_ANNUAL',
|
||||
] as const;
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of PRICE_ENV_KEYS) delete process.env[k];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of PRICE_ENV_KEYS) delete process.env[k];
|
||||
});
|
||||
|
||||
// ── Legacy single-var contract (back-compat) ────────────────────────
|
||||
|
||||
it('returns null when no env vars are set', () => {
|
||||
expect(tierFromPriceId('price_abc123')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps legacy BASIC price env to FREE (Solo)', () => {
|
||||
// BASIC/PRO were collapsed into Solo; STRIPE_PRICE_BASIC is kept as a legacy
|
||||
// alias that now resolves to FREE so a legacy subscriber lands on Solo,
|
||||
// never locked out (see stripe/index.ts).
|
||||
process.env['STRIPE_PRICE_BASIC'] = 'price_basic_test';
|
||||
expect(tierFromPriceId('price_basic_test')).toBe('FREE');
|
||||
});
|
||||
|
||||
it('maps legacy STRIPE_PRICE_PRO to FREE (Solo)', () => {
|
||||
process.env['STRIPE_PRICE_PRO'] = 'price_pro_test';
|
||||
expect(tierFromPriceId('price_pro_test')).toBe('FREE');
|
||||
});
|
||||
|
||||
it('maps legacy STRIPE_PRICE_TEAMS to TEAMS tier', () => {
|
||||
process.env['STRIPE_PRICE_TEAMS'] = 'price_teams_test';
|
||||
expect(tierFromPriceId('price_teams_test')).toBe('TEAMS');
|
||||
});
|
||||
|
||||
it('returns null for unknown price ID with legacy contract', () => {
|
||||
process.env['STRIPE_PRICE_BASIC'] = 'price_basic_test';
|
||||
process.env['STRIPE_PRICE_TEAMS'] = 'price_teams_test';
|
||||
expect(tierFromPriceId('price_unknown')).toBeNull();
|
||||
});
|
||||
|
||||
// ── New 4-var contract (apps/www Next.js port) ──────────────────────
|
||||
|
||||
it('maps STRIPE_PRICE_PRO_MONTHLY to FREE (Solo — legacy PRO price)', () => {
|
||||
process.env['STRIPE_PRICE_PRO_MONTHLY'] = 'price_pro_monthly_test';
|
||||
expect(tierFromPriceId('price_pro_monthly_test')).toBe('FREE');
|
||||
});
|
||||
|
||||
it('maps STRIPE_PRICE_PRO_ANNUAL to FREE (Solo — legacy PRO price)', () => {
|
||||
process.env['STRIPE_PRICE_PRO_ANNUAL'] = 'price_pro_annual_test';
|
||||
expect(tierFromPriceId('price_pro_annual_test')).toBe('FREE');
|
||||
});
|
||||
|
||||
it('maps STRIPE_PRICE_TEAMS_MONTHLY to TEAMS tier', () => {
|
||||
process.env['STRIPE_PRICE_TEAMS_MONTHLY'] = 'price_teams_monthly_test';
|
||||
expect(tierFromPriceId('price_teams_monthly_test')).toBe('TEAMS');
|
||||
});
|
||||
|
||||
it('maps STRIPE_PRICE_TEAMS_ANNUAL to TEAMS tier', () => {
|
||||
process.env['STRIPE_PRICE_TEAMS_ANNUAL'] = 'price_teams_annual_test';
|
||||
expect(tierFromPriceId('price_teams_annual_test')).toBe('TEAMS');
|
||||
});
|
||||
|
||||
// ── Coexistence: both contracts active simultaneously ───────────────
|
||||
|
||||
it('resolves correctly when both new + legacy contracts are set with different IDs', () => {
|
||||
// apps/www landing config + sidecar legacy config on the same env.
|
||||
process.env['STRIPE_PRICE_PRO_MONTHLY'] = 'price_landing_pro_m';
|
||||
process.env['STRIPE_PRICE_PRO_ANNUAL'] = 'price_landing_pro_y';
|
||||
process.env['STRIPE_PRICE_PRO'] = 'price_sidecar_pro';
|
||||
process.env['STRIPE_PRICE_TEAMS_MONTHLY'] = 'price_landing_teams_m';
|
||||
process.env['STRIPE_PRICE_TEAMS_ANNUAL'] = 'price_landing_teams_y';
|
||||
process.env['STRIPE_PRICE_TEAMS'] = 'price_sidecar_teams';
|
||||
|
||||
// Every configured legacy Pro price → FREE (Solo), every Teams price → TEAMS.
|
||||
expect(tierFromPriceId('price_landing_pro_m')).toBe('FREE');
|
||||
expect(tierFromPriceId('price_landing_pro_y')).toBe('FREE');
|
||||
expect(tierFromPriceId('price_sidecar_pro')).toBe('FREE');
|
||||
expect(tierFromPriceId('price_landing_teams_m')).toBe('TEAMS');
|
||||
expect(tierFromPriceId('price_landing_teams_y')).toBe('TEAMS');
|
||||
expect(tierFromPriceId('price_sidecar_teams')).toBe('TEAMS');
|
||||
});
|
||||
|
||||
it('does not cross-pollute tiers (Teams price stays TEAMS, legacy Pro price → FREE)', () => {
|
||||
process.env['STRIPE_PRICE_PRO_MONTHLY'] = 'price_pro_m';
|
||||
process.env['STRIPE_PRICE_TEAMS_MONTHLY'] = 'price_teams_m';
|
||||
|
||||
expect(tierFromPriceId('price_pro_m')).toBe('FREE');
|
||||
expect(tierFromPriceId('price_teams_m')).toBe('TEAMS');
|
||||
// And the negative case explicitly:
|
||||
expect(tierFromPriceId('price_teams_m')).not.toBe('FREE');
|
||||
expect(tierFromPriceId('price_pro_m')).not.toBe('TEAMS');
|
||||
});
|
||||
|
||||
it('returns null for unknown price ID when only the 4-var contract is set', () => {
|
||||
process.env['STRIPE_PRICE_PRO_MONTHLY'] = 'price_pro_m';
|
||||
process.env['STRIPE_PRICE_PRO_ANNUAL'] = 'price_pro_y';
|
||||
process.env['STRIPE_PRICE_TEAMS_MONTHLY'] = 'price_teams_m';
|
||||
process.env['STRIPE_PRICE_TEAMS_ANNUAL'] = 'price_teams_y';
|
||||
|
||||
expect(tierFromPriceId('price_unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tier round-trip via parseTier', () => {
|
||||
it('tier written by updateUserTier is readable via parseTier', () => {
|
||||
updateUserTier(tmpDir, 'TEAMS');
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(tmpDir, 'config.json'), 'utf-8'));
|
||||
const parsed = parseTier(String(raw.tier));
|
||||
expect(parsed).toBe('TEAMS');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Webhook handler: atomic write + idempotency (R1-011) ────────────
|
||||
// Drives the full POST /api/stripe/webhook handler via fastify.inject,
|
||||
// with getStripe mocked (above) and STRIPE_WEBHOOK_SECRET set.
|
||||
describe('webhook handler — atomic write + idempotency', () => {
|
||||
afterEach(() => {
|
||||
nextEvent = null;
|
||||
delete process.env['STRIPE_WEBHOOK_SECRET'];
|
||||
});
|
||||
|
||||
async function buildServer() {
|
||||
const { webhookRoutes } = await import('../../src/stripe/webhook.js');
|
||||
const app = Fastify();
|
||||
app.decorate('localConfig', { dataDir: tmpDir });
|
||||
await app.register(webhookRoutes);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
function postEvent(app: ReturnType<typeof Fastify>) {
|
||||
return app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/webhook',
|
||||
headers: { 'stripe-signature': 't=1,v1=fake', 'content-type': 'application/json' },
|
||||
payload: Buffer.from('{}'),
|
||||
});
|
||||
}
|
||||
|
||||
it('writes valid config.json with no leftover *.tmp file after checkout.session.completed', async () => {
|
||||
process.env['STRIPE_WEBHOOK_SECRET'] = 'whsec_test';
|
||||
// Legacy PRO metadata collapses to FREE (Solo) via parseTier — the write
|
||||
// still lands atomically; the granted tier is FREE.
|
||||
nextEvent = {
|
||||
id: 'evt_atomic_1',
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { payment_status: 'paid', metadata: { tier: 'PRO' }, customer: 'cus_123' } },
|
||||
};
|
||||
|
||||
const app = await buildServer();
|
||||
try {
|
||||
const res = await postEvent(app);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ received: true });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
// config.json exists and is valid JSON with the expected tier
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
expect(raw.tier).toBe('FREE');
|
||||
expect(raw.stripe_customer_id).toBe('cus_123');
|
||||
|
||||
// No torn/leftover temp file remains in dataDir
|
||||
const leftovers = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('is idempotent — replaying the same event.id returns duplicate:true and does not change config', async () => {
|
||||
process.env['STRIPE_WEBHOOK_SECRET'] = 'whsec_test';
|
||||
nextEvent = {
|
||||
id: 'evt_dup_1',
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { payment_status: 'paid', metadata: { tier: 'PRO' }, customer: 'cus_abc' } },
|
||||
};
|
||||
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
|
||||
// First delivery: applies the tier, returns plain received.
|
||||
const app1 = await buildServer();
|
||||
try {
|
||||
const res1 = await postEvent(app1);
|
||||
expect(res1.statusCode).toBe(200);
|
||||
expect(res1.json()).toEqual({ received: true });
|
||||
} finally {
|
||||
await app1.close();
|
||||
}
|
||||
const afterFirst = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(JSON.parse(afterFirst).tier).toBe('FREE');
|
||||
|
||||
// Tamper with the config object the second event WOULD have produced,
|
||||
// so any non-idempotent re-processing would be observable.
|
||||
nextEvent = {
|
||||
id: 'evt_dup_1', // same id
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { payment_status: 'paid', metadata: { tier: 'TEAMS' }, customer: 'cus_xyz' } },
|
||||
};
|
||||
|
||||
const app2 = await buildServer();
|
||||
try {
|
||||
const res2 = await postEvent(app2);
|
||||
expect(res2.statusCode).toBe(200);
|
||||
expect(res2.json()).toEqual({ received: true, duplicate: true });
|
||||
} finally {
|
||||
await app2.close();
|
||||
}
|
||||
|
||||
// Config must be byte-identical to the first write (TEAMS was NOT applied)
|
||||
const afterSecond = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(afterSecond).toBe(afterFirst);
|
||||
expect(JSON.parse(afterSecond).tier).toBe('FREE');
|
||||
});
|
||||
|
||||
// ── R1-002 (webhook path): payment gate ─────────────────────────────
|
||||
// checkout.session.completed fires for UNPAID sessions too (async payment
|
||||
// methods, expired/incomplete checkouts). Granting a paid tier on those is
|
||||
// a free-upgrade bypass. Mirror the sync.ts:46 guard: only payment_status
|
||||
// 'paid' | 'no_payment_required' may grant.
|
||||
it('does NOT grant a tier when payment_status is unpaid (R1-002 webhook gate)', async () => {
|
||||
process.env['STRIPE_WEBHOOK_SECRET'] = 'whsec_test';
|
||||
nextEvent = {
|
||||
id: 'evt_unpaid_1',
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { payment_status: 'unpaid', metadata: { tier: 'TEAMS' }, customer: 'cus_unpaid' } },
|
||||
};
|
||||
|
||||
const app = await buildServer();
|
||||
try {
|
||||
const res = await postEvent(app);
|
||||
// Still ack the event (200) so Stripe stops retrying — but no grant.
|
||||
expect(res.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
// No config.json written at all (no tier ever granted from an unpaid session).
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
const tier = fs.existsSync(configPath)
|
||||
? JSON.parse(fs.readFileSync(configPath, 'utf-8')).tier
|
||||
: undefined;
|
||||
expect(tier).not.toBe('TEAMS');
|
||||
expect(tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it('grants a tier when payment_status is no_payment_required (100%-off coupon)', async () => {
|
||||
process.env['STRIPE_WEBHOOK_SECRET'] = 'whsec_test';
|
||||
// Legacy PRO metadata collapses to FREE (Solo) via parseTier.
|
||||
nextEvent = {
|
||||
id: 'evt_free_1',
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { payment_status: 'no_payment_required', metadata: { tier: 'PRO' }, customer: 'cus_free' } },
|
||||
};
|
||||
|
||||
const app = await buildServer();
|
||||
try {
|
||||
const res = await postEvent(app);
|
||||
expect(res.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(tmpDir, 'config.json'), 'utf-8'));
|
||||
expect(raw.tier).toBe('FREE');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── P2: webhook reachable under the global bearer-auth middleware ────────────
|
||||
// In a hosted (0.0.0.0) deploy the securityMiddleware requires a bearer token on
|
||||
// every /api/* request when a sessionToken is configured. Stripe posts to
|
||||
// /api/stripe/webhook with NO bearer (it can't have our per-process token), so
|
||||
// without an auth exemption the webhook 401s BEFORE the handler and
|
||||
// customer.subscription.deleted/updated never process — cancelled subs never
|
||||
// downgrade. This composes the REAL securityMiddleware + REAL webhookRoutes and
|
||||
// proves a no-auth POST reaches the handler (the route is independently
|
||||
// authenticated by Stripe signature verification inside the handler).
|
||||
describe('P2 — webhook is auth-exempt under securityMiddleware (hosted-deploy reachability)', () => {
|
||||
const SESSION_TOKEN = 'test-session-token-p2';
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-webhook-auth-'));
|
||||
// Exercise the SECURE auth path — the suite default is trust=1, which would
|
||||
// make this vacuous by trusting every loopback caller.
|
||||
process.env.WAGGLE_TRUST_LOCALHOST = '0';
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nextEvent = null;
|
||||
delete process.env.STRIPE_WEBHOOK_SECRET;
|
||||
process.env.WAGGLE_TRUST_LOCALHOST = '1'; // restore suite default
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function buildGuardedServer() {
|
||||
const { webhookRoutes } = await import('../../src/stripe/webhook.js');
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(securityMiddleware, { sessionToken: SESSION_TOKEN });
|
||||
app.decorate('localConfig', { dataDir: tmpDir });
|
||||
// A normal protected route to prove auth IS enforced for non-exempt paths.
|
||||
app.post('/api/other', async () => ({ ok: true }));
|
||||
await app.register(webhookRoutes);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
it('a NO-Authorization POST to /api/stripe/webhook reaches the handler (not 401)', async () => {
|
||||
nextEvent = { id: 'evt_authexempt_1', type: 'customer.subscription.deleted', data: { object: {} } };
|
||||
const app = await buildGuardedServer();
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/stripe/webhook',
|
||||
headers: { 'stripe-signature': 't=1,v1=fake', 'content-type': 'application/json' },
|
||||
payload: Buffer.from('{}'),
|
||||
// NOTE: deliberately NO authorization header.
|
||||
});
|
||||
// Passed the bearer gate and ran the handler → 200. The load-bearing
|
||||
// assertion is that it is NOT a 401 (auth did not block Stripe).
|
||||
expect(res.statusCode).not.toBe(401);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ received: true });
|
||||
// The handler actually processed the cancel → tier downgraded to FREE.
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'config.json'), 'utf-8'));
|
||||
expect(cfg.tier).toBe('FREE');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('a NON-exempt /api/* POST with no token is STILL 401 (exemption is webhook-specific)', async () => {
|
||||
const app = await buildGuardedServer();
|
||||
try {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/other' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().code).toBe('MISSING_TOKEN');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user