/** * End-to-end exercise of the member flows against a running dev server (no browser needed): * forms are submitted the way a no-JS browser would (progressive enhancement of server actions). * * EMAIL_TRANSPORT=console pnpm exec next dev --webpack -p 3001 > /tmp/ri-f1-dev.log & * pnpm exec tsx scripts/e2e-account.mts http://localhost:3001 /tmp/ri-f1-dev.log */ import { readFileSync } from 'node:fs'; import * as OTPAuth from 'otpauth'; const BASE = process.argv[2] ?? 'http://localhost:3001'; const LOG = process.argv[3] ?? '/tmp/ri-f1-dev.log'; const jar = new Map(); const email = `e2e+${Date.now()}@rareindex.io`; const password = 'correct horse battery staple 42'; let failures = 0; function cookieHeader() { return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); } function storeCookies(res: Response) { for (const c of res.headers.getSetCookie()) { const [pair, ...attrs] = c.split(';'); const [k, v] = pair!.split('='); if (!k) continue; const expired = attrs.some((a) => /max-age=0|expires=thu, 01 jan 1970/i.test(a.trim())); if (expired || v === '' || v === undefined) jar.delete(k.trim()); else jar.set(k.trim(), v); } } async function get(path: string) { const res = await fetch(BASE + path, { headers: { cookie: cookieHeader() }, redirect: 'manual' }); storeCookies(res); return { status: res.status, location: res.headers.get('location'), html: await res.text(), type: res.headers.get('content-type') ?? '' }; } function hiddenInputs(formHtml: string): Record { const out: Record = {}; for (const m of formHtml.matchAll(/]*type="hidden"[^>]*>/g)) { const tag = m[0]; const name = tag.match(/name="([^"]*)"/)?.[1]; const value = tag.match(/value="([^"]*)"/)?.[1] ?? ''; if (name) out[name] = value.replace(/"/g, '"').replace(/&/g, '&'); } return out; } /** Find the form containing a given field (or text) and return its hidden inputs. */ function formFor(html: string, marker: string): Record { const chunks = html.split(/
c.includes(marker) && (c.includes('$ACTION') || c.includes('ACTION_ID'))); if (!chunk) throw new Error(`form with marker "${marker}" not found`); return hiddenInputs(chunk.split('
')[0]!); } async function post(path: string, fields: Record, marker: string, opts: { html?: string } = {}) { const page = opts.html ?? (await get(path)).html; const hidden = formFor(page, marker); const fd = new FormData(); for (const [k, v] of Object.entries(hidden)) if (!(k in fields)) fd.append(k, v); for (const [k, v] of Object.entries(fields)) fd.append(k, v); const res = await fetch(BASE + path, { method: 'POST', body: fd, headers: { cookie: cookieHeader() }, redirect: 'manual' }); storeCookies(res); return { status: res.status, location: res.headers.get('location'), html: await res.text() }; } function check(name: string, ok: boolean, detail = '') { console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`); if (!ok) failures++; } function codeFromLog(re: RegExp): string { const log = readFileSync(LOG, 'utf8'); const all = [...log.matchAll(re)]; const last = all[all.length - 1]; if (!last) throw new Error('code not found in log'); return last[1]!; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // 1. signup let r = await post('/signup', { email, password, name: 'E2E Collector', next: '/collections?welcome=1' }, 'name="email"'); check('signup redirects to /verify', r.status === 303 && (r.location ?? '').includes('/verify'), `${r.status} ${r.location}`); await sleep(500); const vcode = codeFromLog(/verification code is (\d{6})/g); check('verification code e-mailed (console)', /^\d{6}$/.test(vcode)); r = await post('/verify', { code: vcode }, 'name="code"'); check('verify → collections (session created)', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`); check('session cookie set', jar.has('ri_session') && jar.has('ri_device')); // 2. authenticated pages for (const p of ['/collections', '/account/settings', '/account/security', '/account/notifications', '/account/api-keys', '/account/data', '/watchlist', '/alerts', '/notifications', '/saved', '/targets', '/deals', '/my-index']) { const g = await get(p); check(`GET ${p}`, g.status === 200, String(g.status)); } // 3. profile handle + currency r = await post('/account/settings', { name: 'E2E Collector', handle: `e2e${Date.now() % 100000}`, bio: 'Testing', displayCurrency: 'CAD' }, 'name="handle"'); check('profile saved (handle + CAD)', r.status === 200 && r.html.includes('Profile saved.')); // 4. collection + item r = await post('/collections', { name: 'E2E Vault', kind: 'collection', description: 'test' }, 'name="kind"'); const colId = (r.location ?? '').match(/\/collections\/(col_[a-z0-9]+)/)?.[1]; check('collection created', r.status === 303 && Boolean(colId), `${r.status} ${r.location}`); r = await post(`/collections/${colId}`, { assetId: 'rare_test0000000000000001', variantId: 'var_test000000000000001', quantity: '1', acquiredAt: '2024-01-15', purchasePrice: '5000', purchaseCurrency: 'CAD', source: 'e2e', grader: '', grade: '', tags: 'grail, test' }, 'name="purchaseCurrency"'); check('item added with CAD cost at 2024 FX', r.status === 200 && r.html.includes('Item added'), r.html.match(/Item added[^<]*/)?.[0]); let page = await get(`/collections/${colId}`); check('collection shows variant RIV (PSA 10 = $19,800 → CA$26,928 at 1.36)', page.html.includes('26,928'), page.html.match(/CA\$[\d,]+/g)?.slice(0, 3).join(' ')); check('collection shows cost basis (5000 CAD / 1.34 = $3,731 → CA$5,075)', page.html.includes('5,075')); r = await post(`/collections/${colId}`, { assetId: 'rare_test0000000000000002', quantity: '1', acquiredAt: '2025-06-01', purchasePrice: '30000', purchaseCurrency: 'USD', grader: '', grade: '' }, 'name="purchaseCurrency"'); check('second item (asset-level RIV) added', r.status === 200 && r.html.includes('Item added')); r = await post(`/collections/${colId}`, { assetId: 'rare_test0000000000000003', quantity: '2', grader: '', grade: '', manualValueUsd: '700' }, 'name="purchaseCurrency"'); check('third item (manual value, no RIV) added', r.status === 200 && r.html.includes('Item added')); page = await get(`/collections/${colId}`); check('allocation & performers rendered', page.html.includes('Allocation by category') && page.html.includes('Best & worst')); const csv = await get(`/api/account/collections/${colId}/export?format=csv`); check('CSV export', csv.status === 200 && csv.type.includes('text/csv') && csv.html.split('\r\n').length >= 4, csv.html.split('\r\n')[0]?.slice(0, 60)); const ins = await get(`/collections/${colId}/insurance`); check('insurance schedule renders', ins.status === 200 && ins.html.includes('Collection schedule')); r = await post(`/collections/${colId}`, { collectionId: colId!, public: 'on' }, 'name="public"'); check('collection made public', r.status === 200 || r.status === 303); // 5. watchlist / alerts / targets / saved r = await post('/watchlist', { targetType: 'asset', targetId: 'rare_test0000000000000001' }, 'name="targetType"'); page = await get('/watchlist'); check('asset watched', page.html.includes('Charizard') && page.html.includes('Unwatch')); r = await post('/alerts', { alertType: 'price_below', targetType: 'asset', targetId: 'rare_test0000000000000001', threshold: '6000', channel: 'inapp', cooldownMinutes: '60' }, 'name="alertType"'); check('alert created', r.status === 200 && r.html.includes('Alert created.')); r = await post('/targets', { assetId: 'rare_test0000000000000002', direction: 'below', targetUsd: '29000' }, 'name="direction"'); check('target created', r.status === 200 && r.html.includes('Target set.')); r = await post('/saved', { name: 'Charizards', url: '/search?q=charizard', notify: 'on' }, 'name="url"'); check('saved search', r.status === 200 && r.html.includes('Search saved.')); page = await get('/deals'); check('Deal Radar lists the -25% test listing', page.html.includes('Charizard') && page.html.includes('-25.00%'), page.html.match(/-\d+\.\d+%/)?.[0]); // 6. API key r = await post('/account/api-keys', { name: 'e2e key' }, 'name="name"'); const key = r.html.match(/ri_(?:free|live)_[A-Za-z0-9_-]+/)?.[0]; check('API key created and shown once', Boolean(key), key?.slice(0, 14)); // 7. MFA setup → logout → login with TOTP r = await post('/account/security', {}, 'Set up authenticator'); const secret = r.html.match(/

([A-Z2-7]{16,})<\/p>/)?.[1]; check('MFA setup started (secret shown)', Boolean(secret)); const totp = new OTPAuth.TOTP({ issuer: 'RareIndex', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secret!) }); r = await post('/account/security', { code: totp.generate() }, 'Code from the app', { html: r.html }); const recovery = [...r.html.matchAll(/

  • ([A-Z2-9]{5}-[A-Z2-9]{5})<\/li>/g)].map((m) => m[1]!); check('MFA enabled; 10 recovery codes shown once', recovery.length === 10, String(recovery.length)); // sign out everywhere else + logout current r = await post('/account/security', {}, 'Sign out everywhere else'); const settings = await get('/account/settings'); const logoutRes = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' }); storeCookies(logoutRes); check('logout route', logoutRes.status === 303 || logoutRes.status === 302, String(logoutRes.status)); jar.delete('ri_device'); // simulate a brand-new device r = await post('/login', { email, password, next: '/collections' }, 'name="email"'); check('login with MFA → /mfa', r.status === 303 && (r.location ?? '').includes('/mfa'), `${r.status} ${r.location}`); r = await post('/mfa', { method: 'totp', code: totp.generate(), trust: 'on' }, 'name="method"'); check('TOTP accepted → /collections', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`); page = await get('/account/security'); check('sign-in history shows totp success', page.html.includes('totp')); // recovery code path const logout2 = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' }); storeCookies(logout2); jar.delete('ri_device'); r = await post('/login', { email, password, next: '/collections' }, 'name="email"'); r = await post('/mfa', { method: 'recovery', code: recovery[0]!.toLowerCase() }, 'name="method"'); check('recovery code accepted', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location} ${r.html.match(/role="alert"[^>]*>([^<]*)/)?.[1] ?? ''}`); r = await post('/mfa', { method: 'recovery', code: recovery[0]! }, 'name="method"').catch(() => ({ status: 0, location: null, html: '' })); check('used recovery code rejected / session gone', r.status !== 303 || !(r.location ?? '').includes('/collections')); // 8. wrong password attempts (logged out) + forgot/reset { const lo = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' }); storeCookies(lo); } for (let i = 0; i < 3; i++) await post('/login', { email, password: 'wrong-password-123' }, 'name="email"'); r = await post('/forgot', { email }, 'name="email"'); check('forgot returns neutral message', r.status === 200 && r.html.includes('reset code is on its way')); await sleep(300); const rcode = codeFromLog(/enter code (\d{6})/g); r = await post('/reset', { email, code: rcode, password: 'another strong passphrase 77' }, 'name="code"'); check('password reset → /login?reset=1', r.status === 303 && (r.location ?? '').includes('reset=1'), `${r.status} ${r.location}`); // 9. data export r = await post('/login', { email, password: 'another strong passphrase 77', next: '/collections' }, 'name="email"'); r = await post('/mfa', { method: 'totp', code: totp.generate(), trust: 'on' }, 'name="method"'); check('login after reset with TOTP', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`); page = await get('/account/security'); check('sign-in history shows bad password attempts', page.html.includes('bad password')); const exp = await get('/api/account/export'); check('JSON export', exp.status === 200 && exp.html.includes('"collections"') && !exp.html.includes('passwordHash')); const prof = await get(`/u/${settings.html.match(/\/u\/([a-z0-9-]+)/)?.[1] ?? 'x'}`); check('public profile renders with public collection', prof.status === 200 && prof.html.includes('E2E Vault'), String(prof.status)); console.log(failures ? `\n${failures} check(s) failed` : '\nAll checks passed'); console.log(`account: ${email}`); process.exit(failures ? 1 : 0);