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.
This commit is contained in:
parent
0f2f1d1cbf
commit
208638e374
37 changed files with 1980 additions and 340 deletions
98
tests/server/api/series-tickets-purchase.test.js
Normal file
98
tests/server/api/series-tickets-purchase.test.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue