diff --git a/scripts/helcim-tunnel.sh b/scripts/helcim-tunnel.sh new file mode 100755 index 0000000..8597a1a --- /dev/null +++ b/scripts/helcim-tunnel.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Public tunnel for testing the Helcim payment flow locally. +# +# Why a production build (not `nuxt dev`): dev mode serves the client as 100+ +# individual ESM modules, which a cloudflared quick tunnel delivers unreliably +# (sporadic 503s on the page-load burst abort hydration — the page renders but +# is dead to clicks). A production bundle is a handful of hashed assets, served +# rock-solid through the tunnel. +# +# BASE_URL + NUXT_PUBLIC_APP_URL are set to the tunnel URL at launch — the Helcim +# signup route requires the request Origin to exactly match BASE_URL. All other +# secrets load from .env via Node's --env-file (shell-set vars take precedence, +# so the tunnel URL wins). .env itself is never modified. +# +# Trade-off: no HMR. Re-run this script after code changes to rebuild. +# +# Usage: ./scripts/helcim-tunnel.sh (Ctrl-C stops the server and the tunnel) + +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + echo "ERROR: .env not found in $(pwd)" >&2 + exit 1 +fi + +LOG="$(mktemp)" +CF_PID="" +SRV_PID="" + +cleanup() { + echo "" + echo "Stopping server and tunnel..." + [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null || true + [ -n "$CF_PID" ] && kill "$CF_PID" 2>/dev/null || true + rm -f "$LOG" +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +echo "Starting cloudflared quick tunnel -> http://localhost:3000 ..." +cloudflared tunnel --url http://localhost:3000 >"$LOG" 2>&1 & +CF_PID=$! + +TUNNEL_URL="" +for _ in $(seq 1 30); do + TUNNEL_URL="$(grep -oE 'https://[a-zA-Z0-9.-]+\.trycloudflare\.com' "$LOG" | head -1 || true)" + [ -n "$TUNNEL_URL" ] && break + sleep 1 +done + +if [ -z "$TUNNEL_URL" ]; then + echo "ERROR: could not obtain a tunnel URL. cloudflared output:" >&2 + cat "$LOG" >&2 + exit 1 +fi +echo "Tunnel live: $TUNNEL_URL" + +echo "Building production bundle (npm run build)..." +npm run build + +echo "" +echo " Serving .output through the tunnel." +echo " Open the app at: $TUNNEL_URL (NOT localhost — Helcim origin check requires it)" +echo " Ctrl-C stops the server and the tunnel." +echo "" + +PORT=3000 \ +HOST=127.0.0.1 \ +BASE_URL="$TUNNEL_URL" \ +NUXT_PUBLIC_APP_URL="$TUNNEL_URL" \ + node --env-file=.env .output/server/index.mjs & +SRV_PID=$! +wait "$SRV_PID" diff --git a/server/api/helcim/verify-payment.post.js b/server/api/helcim/verify-payment.post.js index e00d28d..8359565 100644 --- a/server/api/helcim/verify-payment.post.js +++ b/server/api/helcim/verify-payment.post.js @@ -1,12 +1,17 @@ // Verify payment token from HelcimPay.js -import { requireAuth } from '../../utils/auth.js' +import { requireAuth, getSignupBridgeMember } from '../../utils/auth.js' import { validateBody } from '../../utils/validateBody.js' import { paymentVerifySchema } from '../../utils/schemas.js' import { listHelcimCustomerCards } from '../../utils/helcim.js' export default defineEventHandler(async (event) => { try { - await requireAuth(event) + // Membership signup verifies the card before email verify; allow the + // signup-bridge cookie set by /api/helcim/customer to satisfy auth here. + const bridgeMember = await getSignupBridgeMember(event) + if (!bridgeMember) { + await requireAuth(event) + } const body = await validateBody(event, paymentVerifySchema) // Verify the card token by fetching the customer's cards from Helcim diff --git a/tests/server/api/helcim-payment.test.js b/tests/server/api/helcim-payment.test.js index d5df7f6..5ae55ae 100644 --- a/tests/server/api/helcim-payment.test.js +++ b/tests/server/api/helcim-payment.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js' +import { requireAuth, getOptionalMember, getSignupBridgeMember } from '../../../server/utils/auth.js' import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js' import { loadPublicEvent } from '../../../server/utils/loadEvent.js' import { loadPublicSeries } from '../../../server/utils/loadSeries.js' @@ -12,7 +12,8 @@ import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn(), - getOptionalMember: vi.fn() + getOptionalMember: vi.fn(), + getSignupBridgeMember: vi.fn() })) vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} })) @@ -367,4 +368,27 @@ describe('verify-payment endpoint', () => { statusMessage: 'Payment method not found or does not belong to this customer' }) }) + + it('accepts the signup-bridge cookie without requiring auth', async () => { + const body = { customerId: 'cust-1', cardToken: 'tok-match' } + getSignupBridgeMember.mockResolvedValue({ _id: 'm1' }) + importedValidateBody.mockResolvedValue(body) + + mockFetch.mockResolvedValue({ + ok: true, + text: async () => JSON.stringify([{ cardToken: 'tok-match' }]) + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/verify-payment', + body + }) + + const result = await verifyPaymentHandler(event) + + expect(result.success).toBe(true) + expect(getSignupBridgeMember).toHaveBeenCalledWith(event) + expect(requireAuth).not.toHaveBeenCalled() + }) })