SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
12.6 KB · 194 lines typescript
Raw Blame History
1/**2 * End-to-end exercise of the member flows against a running dev server (no browser needed):3 * forms are submitted the way a no-JS browser would (progressive enhancement of server actions).4 *5 *   EMAIL_TRANSPORT=console pnpm exec next dev --webpack -p 3001 > /tmp/ri-f1-dev.log &6 *   pnpm exec tsx scripts/e2e-account.mts http://localhost:3001 /tmp/ri-f1-dev.log7 */8import { readFileSync } from 'node:fs';9import * as OTPAuth from 'otpauth';1011const BASE = process.argv[2] ?? 'http://localhost:3001';12const LOG = process.argv[3] ?? '/tmp/ri-f1-dev.log';13const jar = new Map<string, string>();14const email = `e2e+${Date.now()}@rareindex.io`;15const password = 'correct horse battery staple 42';16let failures = 0;1718function cookieHeader() {19  return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');20}21function storeCookies(res: Response) {22  for (const c of res.headers.getSetCookie()) {23    const [pair, ...attrs] = c.split(';');24    const [k, v] = pair!.split('=');25    if (!k) continue;26    const expired = attrs.some((a) => /max-age=0|expires=thu, 01 jan 1970/i.test(a.trim()));27    if (expired || v === '' || v === undefined) jar.delete(k.trim());28    else jar.set(k.trim(), v);29  }30}31async function get(path: string) {32  const res = await fetch(BASE + path, { headers: { cookie: cookieHeader() }, redirect: 'manual' });33  storeCookies(res);34  return { status: res.status, location: res.headers.get('location'), html: await res.text(), type: res.headers.get('content-type') ?? '' };35}36function hiddenInputs(formHtml: string): Record<string, string> {37  const out: Record<string, string> = {};38  for (const m of formHtml.matchAll(/<input[^>]*type="hidden"[^>]*>/g)) {39    const tag = m[0];40    const name = tag.match(/name="([^"]*)"/)?.[1];41    const value = tag.match(/value="([^"]*)"/)?.[1] ?? '';42    if (name) out[name] = value.replace(/&quot;/g, '"').replace(/&amp;/g, '&');43  }44  return out;45}46/** Find the form containing a given field (or text) and return its hidden inputs. */47function formFor(html: string, marker: string): Record<string, string> {48  const chunks = html.split(/<form/).slice(1);49  const chunk = chunks.find((c) => c.includes(marker) && (c.includes('$ACTION') || c.includes('ACTION_ID')));50  if (!chunk) throw new Error(`form with marker "${marker}" not found`);51  return hiddenInputs(chunk.split('</form>')[0]!);52}53async function post(path: string, fields: Record<string, string | Blob>, marker: string, opts: { html?: string } = {}) {54  const page = opts.html ?? (await get(path)).html;55  const hidden = formFor(page, marker);56  const fd = new FormData();57  for (const [k, v] of Object.entries(hidden)) if (!(k in fields)) fd.append(k, v);58  for (const [k, v] of Object.entries(fields)) fd.append(k, v);59  const res = await fetch(BASE + path, { method: 'POST', body: fd, headers: { cookie: cookieHeader() }, redirect: 'manual' });60  storeCookies(res);61  return { status: res.status, location: res.headers.get('location'), html: await res.text() };62}63function check(name: string, ok: boolean, detail = '') {64  console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);65  if (!ok) failures++;66}67function codeFromLog(re: RegExp): string {68  const log = readFileSync(LOG, 'utf8');69  const all = [...log.matchAll(re)];70  const last = all[all.length - 1];71  if (!last) throw new Error('code not found in log');72  return last[1]!;73}74const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));7576// 1. signup77let r = await post('/signup', { email, password, name: 'E2E Collector', next: '/collections?welcome=1' }, 'name="email"');78check('signup redirects to /verify', r.status === 303 && (r.location ?? '').includes('/verify'), `${r.status} ${r.location}`);79await sleep(500);80const vcode = codeFromLog(/verification code is (\d{6})/g);81check('verification code e-mailed (console)', /^\d{6}$/.test(vcode));82r = await post('/verify', { code: vcode }, 'name="code"');83check('verify → collections (session created)', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`);84check('session cookie set', jar.has('ri_session') && jar.has('ri_device'));8586// 2. authenticated pages87for (const p of ['/collections', '/account/settings', '/account/security', '/account/notifications', '/account/api-keys', '/account/data', '/watchlist', '/alerts', '/notifications', '/saved', '/targets', '/deals', '/my-index']) {88  const g = await get(p);89  check(`GET ${p}`, g.status === 200, String(g.status));90}9192// 3. profile handle + currency93r = await post('/account/settings', { name: 'E2E Collector', handle: `e2e${Date.now() % 100000}`, bio: 'Testing', displayCurrency: 'CAD' }, 'name="handle"');94check('profile saved (handle + CAD)', r.status === 200 && r.html.includes('Profile saved.'));9596// 4. collection + item97r = await post('/collections', { name: 'E2E Vault', kind: 'collection', description: 'test' }, 'name="kind"');98const colId = (r.location ?? '').match(/\/collections\/(col_[a-z0-9]+)/)?.[1];99check('collection created', r.status === 303 && Boolean(colId), `${r.status} ${r.location}`);100r = 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"');101check('item added with CAD cost at 2024 FX', r.status === 200 && r.html.includes('Item added'), r.html.match(/Item added[^<]*/)?.[0]);102let page = await get(`/collections/${colId}`);103check('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(' '));104check('collection shows cost basis (5000 CAD / 1.34 = $3,731 → CA$5,075)', page.html.includes('5,075'));105r = await post(`/collections/${colId}`, { assetId: 'rare_test0000000000000002', quantity: '1', acquiredAt: '2025-06-01', purchasePrice: '30000', purchaseCurrency: 'USD', grader: '', grade: '' }, 'name="purchaseCurrency"');106check('second item (asset-level RIV) added', r.status === 200 && r.html.includes('Item added'));107r = await post(`/collections/${colId}`, { assetId: 'rare_test0000000000000003', quantity: '2', grader: '', grade: '', manualValueUsd: '700' }, 'name="purchaseCurrency"');108check('third item (manual value, no RIV) added', r.status === 200 && r.html.includes('Item added'));109page = await get(`/collections/${colId}`);110check('allocation & performers rendered', page.html.includes('Allocation by category') && page.html.includes('Best &amp; worst'));111const csv = await get(`/api/account/collections/${colId}/export?format=csv`);112check('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));113const ins = await get(`/collections/${colId}/insurance`);114check('insurance schedule renders', ins.status === 200 && ins.html.includes('Collection schedule'));115r = await post(`/collections/${colId}`, { collectionId: colId!, public: 'on' }, 'name="public"');116check('collection made public', r.status === 200 || r.status === 303);117118// 5. watchlist / alerts / targets / saved119r = await post('/watchlist', { targetType: 'asset', targetId: 'rare_test0000000000000001' }, 'name="targetType"');120page = await get('/watchlist');121check('asset watched', page.html.includes('Charizard') && page.html.includes('Unwatch'));122r = await post('/alerts', { alertType: 'price_below', targetType: 'asset', targetId: 'rare_test0000000000000001', threshold: '6000', channel: 'inapp', cooldownMinutes: '60' }, 'name="alertType"');123check('alert created', r.status === 200 && r.html.includes('Alert created.'));124r = await post('/targets', { assetId: 'rare_test0000000000000002', direction: 'below', targetUsd: '29000' }, 'name="direction"');125check('target created', r.status === 200 && r.html.includes('Target set.'));126r = await post('/saved', { name: 'Charizards', url: '/search?q=charizard', notify: 'on' }, 'name="url"');127check('saved search', r.status === 200 && r.html.includes('Search saved.'));128page = await get('/deals');129check('Deal Radar lists the -25% test listing', page.html.includes('Charizard') && page.html.includes('-25.00%'), page.html.match(/-\d+\.\d+%/)?.[0]);130131// 6. API key132r = await post('/account/api-keys', { name: 'e2e key' }, 'name="name"');133const key = r.html.match(/ri_(?:free|live)_[A-Za-z0-9_-]+/)?.[0];134check('API key created and shown once', Boolean(key), key?.slice(0, 14));135136// 7. MFA setup → logout → login with TOTP137r = await post('/account/security', {}, 'Set up authenticator');138const secret = r.html.match(/<p class="mono-num[^"]*">([A-Z2-7]{16,})<\/p>/)?.[1];139check('MFA setup started (secret shown)', Boolean(secret));140const totp = new OTPAuth.TOTP({ issuer: 'RareIndex', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secret!) });141r = await post('/account/security', { code: totp.generate() }, 'Code from the app', { html: r.html });142const recovery = [...r.html.matchAll(/<li>([A-Z2-9]{5}-[A-Z2-9]{5})<\/li>/g)].map((m) => m[1]!);143check('MFA enabled; 10 recovery codes shown once', recovery.length === 10, String(recovery.length));144// sign out everywhere else + logout current145r = await post('/account/security', {}, 'Sign out everywhere else');146const settings = await get('/account/settings');147const logoutRes = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' });148storeCookies(logoutRes);149check('logout route', logoutRes.status === 303 || logoutRes.status === 302, String(logoutRes.status));150jar.delete('ri_device'); // simulate a brand-new device151r = await post('/login', { email, password, next: '/collections' }, 'name="email"');152check('login with MFA → /mfa', r.status === 303 && (r.location ?? '').includes('/mfa'), `${r.status} ${r.location}`);153r = await post('/mfa', { method: 'totp', code: totp.generate(), trust: 'on' }, 'name="method"');154check('TOTP accepted → /collections', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`);155page = await get('/account/security');156check('sign-in history shows totp success', page.html.includes('totp'));157// recovery code path158const logout2 = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' });159storeCookies(logout2);160jar.delete('ri_device');161r = await post('/login', { email, password, next: '/collections' }, 'name="email"');162r = await post('/mfa', { method: 'recovery', code: recovery[0]!.toLowerCase() }, 'name="method"');163check('recovery code accepted', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location} ${r.html.match(/role="alert"[^>]*>([^<]*)/)?.[1] ?? ''}`);164r = await post('/mfa', { method: 'recovery', code: recovery[0]! }, 'name="method"').catch(() => ({ status: 0, location: null, html: '' }));165check('used recovery code rejected / session gone', r.status !== 303 || !(r.location ?? '').includes('/collections'));166167// 8. wrong password attempts (logged out) + forgot/reset168{169  const lo = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' });170  storeCookies(lo);171}172for (let i = 0; i < 3; i++) await post('/login', { email, password: 'wrong-password-123' }, 'name="email"');173r = await post('/forgot', { email }, 'name="email"');174check('forgot returns neutral message', r.status === 200 && r.html.includes('reset code is on its way'));175await sleep(300);176const rcode = codeFromLog(/enter code (\d{6})/g);177r = await post('/reset', { email, code: rcode, password: 'another strong passphrase 77' }, 'name="code"');178check('password reset → /login?reset=1', r.status === 303 && (r.location ?? '').includes('reset=1'), `${r.status} ${r.location}`);179180// 9. data export181r = await post('/login', { email, password: 'another strong passphrase 77', next: '/collections' }, 'name="email"');182r = await post('/mfa', { method: 'totp', code: totp.generate(), trust: 'on' }, 'name="method"');183check('login after reset with TOTP', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`);184page = await get('/account/security');185check('sign-in history shows bad password attempts', page.html.includes('bad password'));186const exp = await get('/api/account/export');187check('JSON export', exp.status === 200 && exp.html.includes('"collections"') && !exp.html.includes('passwordHash'));188const prof = await get(`/u/${settings.html.match(/\/u\/([a-z0-9-]+)/)?.[1] ?? 'x'}`);189check('public profile renders with public collection', prof.status === 200 && prof.html.includes('E2E Vault'), String(prof.status));190191console.log(failures ? `\n${failures} check(s) failed` : '\nAll checks passed');192console.log(`account: ${email}`);193process.exit(failures ? 1 : 0);194