2dfccf0039
Marty signed in and was told "You're joining the line of @bliss". He is member #1. Cause: the last-touch sponsor cookie lives 30 days, and /api/sponsor set invited purely from that cookie. So any member who had ever clicked a teammate's invite link was greeted on the sign-in screen as though logging in would place them under that person. Untrue, and alarming in exactly the wrong place: their sponsor locked at their first purchase and nothing on that screen can move it. Anyone seeing that would reasonably worry their line was about to change. The greeting now shows when someone actually arrived through a link (?ref= in the URL), or when the cookie is present AND this browser has never had an account, which is the genuine "came back later to finish joining" case. A browser that already has an account, or a signed-in session, never sees it. Attribution is deliberately untouched: the cookie still resolves, the sponsor id is still returned, and placement still works exactly as before. Only the greeting changed. fraud.hasAccountOnDevice(req) is the new signal, reusing the device cookie the one-account-per-person checks already set. qa/sponsor-note.mjs (5 assertions) boots its own throwaway server and creates a REAL account so the case is proven rather than assumed: still greeted with ?ref=, still greeted from the cookie on a browser with no account, NOT greeted on the browser that has one, and attribution still resolving. qa/run.sh member: 0 bugs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
72 lines
3.9 KiB
JavaScript
72 lines
3.9 KiB
JavaScript
// "You're joining the line of X" must only greet someone actually about to join.
|
|
//
|
|
// The last-touch sponsor cookie lives 30 days, so an EXISTING member who once clicked a
|
|
// teammate's invite link was being told on the sign-in page that logging in would place
|
|
// them under that person (Marty, 2026-09-17). Untrue and alarming: their sponsor locked
|
|
// at their first purchase and nothing on that screen can move it.
|
|
//
|
|
// Boots its own throwaway server so the account row it needs is real, not assumed.
|
|
// node qa/sponsor-note.mjs
|
|
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const PORT = 8799;
|
|
const B = 'http://127.0.0.1:' + PORT;
|
|
const DATA = path.join(os.tmpdir(), 'iap-sponsor-note-' + Date.now());
|
|
fs.mkdirSync(DATA, { recursive: true });
|
|
const ok = [], bad = [];
|
|
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
|
|
|
|
const srv = spawn(process.execPath, ['server.js'], {
|
|
env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, ADMIN_PASSWORD: 'localtest', ADMIN_EMAIL: 'admin@example.com', NODE_ENV: 'test' },
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
const bye = code => { try { srv.kill(); } catch (e) {} process.exit(code); };
|
|
for (let i = 0; i < 60; i++) {
|
|
try { const r = await fetch(B + '/api/stats'); if (r.ok) break; } catch (e) {}
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
|
|
const j = async (p, opts) => { const r = await fetch(B + p, opts); return { status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }; };
|
|
const ask = (cookie, qs) => fetch(B + '/api/sponsor' + (qs || ''), { headers: cookie ? { Cookie: cookie } : {} }).then(r => r.json());
|
|
|
|
// make a real member on device DEV_A, the way a person would
|
|
const DEV_A = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
|
const DEV_B = '0f9e8d7c6b5a49382716f5e4d3c2b1a0';
|
|
const EMAIL = 'returning@example.com';
|
|
const start = await j('/api/auth/email/start', { method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Cookie: 'iap.dev=' + DEV_A },
|
|
body: JSON.stringify({ email: EMAIL, fts: Date.now() - 20000 }) });
|
|
const code = start.body && (start.body.devCode || start.body.code);
|
|
if (!code) { console.log('could not start sign-up (no devCode in test mode):', JSON.stringify(start.body).slice(0, 200)); bye(2); }
|
|
const fin = await j('/api/auth/email/verify', { method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Cookie: 'iap.dev=' + DEV_A },
|
|
body: JSON.stringify({ email: EMAIL, code }) });
|
|
t('a real account was created for the test', fin.status === 200 && !fin.body.error, JSON.stringify(fin.body).slice(0, 160));
|
|
|
|
const REF = 'house';
|
|
// 1. arriving THROUGH a link: greeting is correct and wanted, even on that device
|
|
const arriving = await ask('iap.dev=' + DEV_A, '?ref=' + REF);
|
|
t('someone arriving with ?ref= is still greeted', arriving.invited === true, JSON.stringify(arriving).slice(0, 140));
|
|
|
|
// 2. a browser with NO account, carrying only the stored invite cookie: still greeted,
|
|
// because they genuinely came back to finish joining
|
|
const fresh = await ask('iap.sponsor=' + REF + '; iap.dev=' + DEV_B);
|
|
t('a browser with no account is still greeted from the cookie', fresh.invited === true, JSON.stringify(fresh).slice(0, 140));
|
|
|
|
// 3. THE BUG: a browser that already has an account, carrying a stale invite cookie
|
|
const existing = await ask('iap.sponsor=' + REF + '; iap.dev=' + DEV_A);
|
|
t('a browser that already has an account is NOT told it is joining anyone',
|
|
existing.invited === false, JSON.stringify(existing).slice(0, 180));
|
|
|
|
// 4. attribution itself must be untouched: the ref is still resolved and returned
|
|
t('the sponsor reference is still resolved for attribution',
|
|
existing.ref === REF || existing.sponsorId > 0, JSON.stringify(existing).slice(0, 180));
|
|
|
|
console.log('PASS ' + ok.length);
|
|
for (const b of bad) console.log('FAIL ' + b);
|
|
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
|
|
bye(bad.length ? 1 : 0);
|