ghostguild-org/tests/server/api/series-tickets-purchase.test.js
Jennie Robinson Faber 208638e374 feat(launch): security and correctness fixes for 2026-05-01 launch
Day-of-launch deep-dive audit and remediation. 11 issues fixed across
security, correctness, and reliability. Tests: 698 → 758 passing
(+60), 0 failing, 2 skipped.

CRITICAL (security)

Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead
useHelcim.js deleted. Production token MUST BE ROTATED post-deploy
(was previously exposed in window.__NUXT__ payload).

Fix #2 — /api/helcim/customer gated with origin check + per-IP/email
rate limit + magic-link email verification (replaces unauthenticated
setAuthCookie). Adds payment-bridge token for paid-tier signup so
users can complete Helcim checkout before email verify. New utils:
server/utils/{magicLink,rateLimit}.js. UX: signup success copy now
prompts user to check email.

Fix #3 — /api/events/[id]/payment deleted (dead code with unauth
member-spoof bypass — processHelcimPayment was a permanent stub).
Removes processHelcimPayment export and eventPaymentSchema.

Fix #4 — /api/helcim/initialize-payment re-derives ticket amount
server-side via calculateTicketPrice and calculateSeriesTicketPrice.
Adds new series_ticket metadata type (was being shoved through
event_ticket with seriesId in metadata.eventId).

Fix #5 — /api/helcim/customer upgrades existing status:guest members
in place rather than rejecting with 409. Lowercases email at lookup;
preserves _id so prior event registrations stay linked.

HIGH (correctness / reliability)

Fix #6 — Daily reconciliation cron via Netlify scheduled function
(@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs,
server/api/internal/reconcile-payments.post.js. Shared-secret auth
via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff
on Helcim transactions API.

Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist
endpoints) to dodge legacy location validators.

Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest
Member when caller is unauthenticated, mirrors event-ticket flow
byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and
client auth refresh on signedIn:true response.

Fix #9 — /api/members/cancel-subscription leaves status active per
ratified bylaws (was pending_payment). Adds lastCancelledAt audit
field on Member model. Indirectly fixes false-positive
detectStuckPendingPayment admin alert for cancelled members.

Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema
(verifyMagicLinkSchema, max 2000 chars).

Fix #11 — 8 vitest cases for cancel-subscription handler (was
uncovered).

Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and
docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md.
LAUNCH_READINESS.md updated with new test count, 3 deploy-time
tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify
Netlify scheduled function), and Fixed-2026-04-25 fix log.
2026-04-25 18:42:36 +01:00

98 lines
4.5 KiB
JavaScript

import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]')
describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => {
const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8')
it('uses validateBody with seriesTicketPurchaseSchema', () => {
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
})
it('Case 1 (free) + Case 2 (paid): upserts a guest Member when unauthenticated buyer provides name+email', () => {
// Mirror event endpoint upsert pattern; ALWAYS-CREATE-GUEST (no opt-in
// checkbox), so guard is `if (!member)` rather than `if (!member && body.createAccount)`.
expect(source).toContain('findOneAndUpdate')
expect(source).toContain('$setOnInsert')
expect(source).toContain('status: "guest"')
expect(source).toContain('upsert: true')
expect(source).toContain('circle: "community"')
expect(source).toContain('contributionAmount: 0')
// ALWAYS-CREATE — must NOT gate on a createAccount flag
expect(source).not.toContain('body.createAccount')
})
it('Case 3 (idempotency): upsert pattern handles concurrent same-email registrations atomically', () => {
// findOneAndUpdate with $setOnInsert + upsert:true is the idempotent pattern;
// email has a unique index. No duplicate Member doc created on retry.
expect(source).toMatch(/findOneAndUpdate\(\s*\{\s*email:/)
expect(source).toContain('upsert: true')
expect(source).toContain('new: true')
expect(source).toContain('setDefaultsOnInsert: true')
})
it('Case 4 (existing real member): does not auto-login real members entered via public form', () => {
// Auto-login only for newly-created accounts and existing guests.
// Real members (active/pending_payment) get requiresSignIn: true instead.
expect(source).toContain('accountCreated || member.status === "guest"')
expect(source).toContain('requiresSignIn = true')
})
it('Case 5 (authenticated guest): sets auth cookie on signedIn:true response', () => {
// setAuthCookie fires for both new accounts and returning guests.
expect(source).toContain('setAuthCookie(event, member)')
expect(source).toContain('signedIn = true')
})
it('Case 6 (missing fields): relies on schema validation to reject missing name/email', () => {
// No new validation logic added — existing seriesTicketPurchaseSchema
// already requires name+email; validateBody throws 400 if missing.
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
})
it('includes accountCreated, signedIn, and requiresSignIn in response (parity with event endpoint)', () => {
expect(source).toContain('accountCreated,')
expect(source).toContain('signedIn,')
expect(source).toContain('requiresSignIn,')
})
it('still uses hasMemberAccess to gate member pricing (guest/suspended/cancelled treated as non-members)', () => {
expect(source).toContain('hasMemberAccess(member)')
})
it('preserves try/catch around requireAuth so unauthenticated callers fall through', () => {
// Required for unauth guest-purchase flow to work at all.
expect(source).toMatch(/try\s*\{[^}]*requireAuth\(event\)[^}]*\}\s*catch/s)
})
it('does not block purchase when confirmation email fails', () => {
const emailCallIndex = source.indexOf('await sendSeriesPassConfirmation')
expect(emailCallIndex).toBeGreaterThan(-1)
const afterEmail = source.slice(emailCallIndex)
const catchBlock = afterEmail.match(/catch\s*\(\w+\)\s*\{[^}]*\}/s)
expect(catchBlock).not.toBeNull()
expect(catchBlock[0]).toContain('console.error')
})
})
describe('SeriesPassPurchase.vue — client auth refresh (Fix #8)', () => {
const source = readFileSync(
resolve(import.meta.dirname, '../../../app/components/SeriesPassPurchase.vue'),
'utf-8'
)
it('refreshes client auth state via useAuth().checkMemberStatus() when server reports signedIn', () => {
expect(source).toContain('useAuth().checkMemberStatus()')
expect(source).toMatch(/purchaseResponse\?\.signedIn/)
})
it('shows a one-line guest-account hint under the form (no checkbox)', () => {
// Per ALWAYS-CREATE-GUEST decision: hint only, no UI control.
expect(source).toMatch(/free guest account/i)
// Make sure no checkbox was added by mistake.
expect(source).not.toMatch(/createAccount/)
expect(source).not.toMatch(/<input[^>]*type="checkbox"/i)
})
})