diff --git a/qa/README.md b/qa/README.md index ecc43da..36e1528 100644 --- a/qa/README.md +++ b/qa/README.md @@ -23,3 +23,25 @@ at step 2, visitors on every ID-keyed shared link seeing no gate and no 401, and `devCode` is returned by `/api/public/profile/email-start` only when `NODE_ENV !== 'production'`, which is what lets the test read the code. The live container runs with NODE_ENV=production. + +## Join flow (the money path) + +Drives the REAL /join-now page with a fake wallet that produces genuine secp256k1 signatures, against +a server whose chain reads are stubbed by `qa/harness-server.js`. `COLD=1` reproduces the state that +left #787 without a profile: the cached index does not yet know the brand-new position. + +``` +node -e "const c=require('crypto'),s=require('./vendor/secp256k1.js'),{keccak256}=require('./vendor/sha3.js');s.utils.hmacSha256Sync=(k,...m)=>{const h=c.createHmac('sha256',Buffer.from(k));m.forEach(x=>h.update(Buffer.from(x)));return Uint8Array.from(h.digest())};const p=c.randomBytes(32),pub=s.getPublicKey(p,false);require('fs').writeFileSync('D:/tmp/rmc-qa-wallet.json',JSON.stringify({priv:p.toString('hex'),addr:'0x'+keccak256(Buffer.from(pub.slice(1))).slice(-40)}))" + +TMP=/d/tmp/rmc-jd && rm -rf $TMP && mkdir -p $TMP && echo '[]' > $TMP/sponsors.json +ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $TMP/config.json +TEST_ADDR= TEST_ID=9001 COLD=1 PORT=3399 DATA_DIR=$TMP ADMIN_PASSWORD=localtest node qa/harness-server.js & +LOCAL=http://127.0.0.1:3399 TEST_ADDR= TEST_PRIV= TEST_ID=9001 SCENARIO=sign node qa/join-flow-e2e.mjs +LOCAL=http://127.0.0.1:3399 TEST_ADDR= TEST_PRIV= TEST_ID=9002 SCENARIO=refuse node qa/join-flow-e2e.mjs +``` + +`SCENARIO=refuse` is the regression that matters most: a member who declines the signature must still +complete the join and reach their dashboard. Never ship a join-flow change without it passing. + +Gotchas: seed `sponsors.json` as `[]` (an object 500s), and the fake wallet auto-connects so +`#connectBtn` is hidden. Counters live in sessionStorage because the page redirects. diff --git a/qa/harness-server.js b/qa/harness-server.js new file mode 100644 index 0000000..903baca --- /dev/null +++ b/qa/harness-server.js @@ -0,0 +1,42 @@ +// QA harness: boots the real server with the chain reads stubbed, so the join flow +// can be driven end to end without spending POL or waiting on the indexer. +// +// TEST_ADDR=0x.. TEST_ID=9001 COLD=1 PORT=3399 DATA_DIR=... node qa/harness-server.js +// +// COLD=1 reproduces the state that broke #787: the cached index does NOT know the +// brand-new position, so only a live contract read can resolve it. +const path = require('path'); +process.chdir(path.join(__dirname, '..')); +const chain = require('../chain.js'); + +const ADDR = String(process.env.TEST_ADDR || '').toLowerCase(); +const ID = Number(process.env.TEST_ID || 9001); +const COLD = process.env.COLD !== '0'; +let seeded = !COLD; // warm index = already knows the member + +chain.startIndexer = function () { /* no RPC polling in the harness */ }; +chain.memberIdByAccount = function (a) { + return (seeded && String(a || '').toLowerCase() === ADDR) ? ID : null; +}; +chain.verifyMember = async function (id) { + if (Number(id) !== ID) return { registered: false }; + seeded = true; // a live read seeds the index, as the real one does + return { registered: true, id: ID, account: ADDR, referrerId: 21, uplineId: 21, tier: 2, level: 1, + directCount: 0, joinedAt: Math.floor(Date.now() / 1000), tierName: 'Premium', levelName: 'Scintilla' }; +}; +chain.memberPublic = async function (id) { return Number(id) === ID ? { registered: true, id: ID, level: 1, tier: 2, directCount: 0 } : { registered: false, id }; }; +chain.memberLookup = async function (id) { return chain.memberPublic(id); }; +chain.isInTeam = function () { return true; }; +chain.getPayoutsPublic = function () { return []; }; +chain.getIncome = async function () { return []; }; +chain.getMatrixTree = async function () { return null; }; +chain.getCoachingScan = async function () { return { rows: [] }; }; +chain.liveDirects = async function () { return []; }; +chain.balanceOf = async function () { return '0'; }; +chain.getOwnerUpgradeNeeds = async function () { return null; }; +chain.getOrgRouting = async function () { return null; }; +chain.getOrgShare = async function () { return null; }; +chain.nextOpenPosition = function () { return 21; }; + +console.log('harness: addr=' + ADDR + ' id=' + ID + ' coldIndex=' + COLD); +require('../server.js'); diff --git a/qa/join-flow-e2e.mjs b/qa/join-flow-e2e.mjs new file mode 100644 index 0000000..17aafcd --- /dev/null +++ b/qa/join-flow-e2e.mjs @@ -0,0 +1,132 @@ +// End-to-end QA of the REAL join flow, with a fake wallet that produces genuine +// secp256k1 signatures, against a server whose chain reads are stubbed cold — +// exactly the state that left #787 without a profile. +// +// Two runs matter: +// SCENARIO=sign member approves the signature -> profile gate must appear +// SCENARIO=refuse member rejects the signature -> the JOIN MUST STILL COMPLETE +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import crypto from 'node:crypto'; +const ROOT = 'D:/Projects/HighRisk/The RM Circle/promos/'; +const require = createRequire(ROOT + 'package.json'); +const secp = require(ROOT + 'vendor/secp256k1.js'); +const { keccak256 } = require(ROOT + 'vendor/sha3.js'); +secp.utils.hmacSha256Sync = (key, ...msgs) => { + const h = crypto.createHmac('sha256', Buffer.from(key)); + msgs.forEach(m => h.update(Buffer.from(m))); + return Uint8Array.from(h.digest()); +}; +const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; +const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; + +const B = process.env.LOCAL || 'http://127.0.0.1:3399'; +const SCENARIO = process.env.SCENARIO || 'sign'; +const PRIV = Buffer.from(process.env.TEST_PRIV, 'hex'); +const ADDR = process.env.TEST_ADDR; +const NEW_ID = Number(process.env.TEST_ID || 9001); +const CONTRACT = '0x33bdaeefd6d17d80ae53816c916dfb26c4fb2daf'; +const T_REGISTERED = '0xe4a74887d749eb048f14bfef37b204477f3a5ff67055908b7c8cc62c202aef17'; + +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } }); +const page = await ctx.newPage(); + +// real signing, called from the page (exposeFunction sidesteps the page's strict CSP) +await page.exposeFunction('__qaSign', async (msg) => { + const m = Buffer.from(msg, 'utf8'); + const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8'); + const digest = Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex'); + const [sig, rec] = secp.signSync(digest, PRIV, { recovered: true, der: false }); + return '0x' + Buffer.from(sig).toString('hex') + (27 + rec).toString(16).padStart(2, '0'); +}); + +await page.addInitScript(({ addr, newId, contract, topic, scenario }) => { + const TX = '0x' + 'ab'.repeat(32); + let sent = false; + // counters live in sessionStorage so they survive the redirect to /my/ + const bump = (k) => { try { const s = JSON.parse(sessionStorage.getItem('__qa') || '{}'); s[k] = (s[k] || 0) + 1; sessionStorage.setItem('__qa', JSON.stringify(s)); } catch (e) {} }; + window.__qaRead = () => { try { return JSON.parse(sessionStorage.getItem('__qa') || '{}'); } catch (e) { return {}; } }; + window.ethereum = { + isMetaMask: true, + request: async ({ method, params }) => { + switch (method) { + case 'eth_requestAccounts': case 'eth_accounts': return [addr]; + case 'eth_chainId': return '0x89'; + case 'eth_getBalance': return '0x' + (10n ** 21n).toString(16); // 1000 POL, plenty + case 'eth_call': throw new Error('no node in the harness'); // page falls back to its constant + case 'eth_sendTransaction': bump('txSent'); sent = true; return TX; + case 'eth_getTransactionReceipt': + if (!sent) return null; + return { status: '0x1', transactionHash: TX, logs: [{ address: contract, topics: [topic, '0x' + newId.toString(16).padStart(64, '0')], data: '0x' }] }; + case 'personal_sign': { + bump('signAsked'); + if (scenario === 'refuse') { bump('signRefused'); const e = new Error('User rejected the request.'); e.code = 4001; throw e; } + const hex = String(params[0]).replace(/^0x/, ''); + let s = ''; for (let i = 0; i < hex.length; i += 2) s += String.fromCharCode(parseInt(hex.substr(i, 2), 16)); + return await window.__qaSign(s); + } + case 'wallet_switchEthereumChain': case 'wallet_addEthereumChain': return null; + default: return null; + } + }, + on: () => {}, removeListener: () => {} + }; +}, { addr: ADDR, newId: NEW_ID, contract: CONTRACT, topic: T_REGISTERED, scenario: SCENARIO }); + +const errs = []; +page.on('console', m => { if (m.type() === 'error') errs.push(m.text().slice(0, 120)); }); +page.on('pageerror', e => errs.push('PAGEERROR ' + String(e.message).slice(0, 120))); + +await page.goto(B + '/join-now?ref=21&direct=1', { waitUntil: 'networkidle', timeout: 60000 }); +await page.waitForTimeout(1500); +t('join page loads', await page.evaluate(() => !!document.getElementById('joinBtn'))); + +// the page auto-connects when the wallet already reports an account, which hides #connectBtn +const needsConnect = await page.evaluate(() => { const b = document.getElementById('connectBtn'); return !!b && getComputedStyle(b).display !== 'none'; }); +if (needsConnect) { await page.click('#connectBtn'); } +await page.waitForTimeout(2000); +t('wallet connects', await page.evaluate(() => { const w = document.getElementById('wallet'); return !!(w && w.textContent && w.textContent.length > 6); }), + await page.evaluate(() => { const w = document.getElementById('wallet'); return w ? w.textContent : 'no #wallet'; })); + +await page.click('#joinBtn'); +// registration + report + (maybe) signature + redirect +await page.waitForURL(u => /\/my\//.test(u.toString()), { timeout: 60000 }).catch(() => {}); +await page.waitForTimeout(3500); + +const qa = await page.evaluate(() => (window.__qaRead ? window.__qaRead() : {})).catch(() => ({})); +t('the registration transaction was sent', (qa.txSent || 0) >= 1 || /\/my\//.test(page.url()), JSON.stringify(qa)); +t('the member lands on their position page', page.url().includes('/my/'), page.url()); + +const cookies = await ctx.cookies(); +const hasSession = cookies.some(c => c.name === 'ctb.msid'); + +if (SCENARIO === 'sign') { + t('a signature was requested right after the join', (qa.signAsked || 0) >= 1, JSON.stringify(qa)); + t('an inbox session was created', hasSession, cookies.map(c => c.name).join(',')); + await page.waitForTimeout(2500); + const gate = await page.evaluate(() => !!document.querySelector('#pgCard')); + t('the profile gate appears on their dashboard', gate); + const card = await page.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); + t('the gate is for THEIR position', new RegExp('position #' + NEW_ID, 'i').test(card), card.slice(0, 90)); +} else { + t('the signature was refused by the wallet', (qa.signRefused || 0) >= 1, JSON.stringify(qa)); + t('REGRESSION: the join still completed and redirected', /\/my\//.test(page.url()), page.url()); + t('no inbox session, as expected', !hasSession); + const gate = await page.evaluate(() => !!document.querySelector('#pgCard')); + t('no gate without a session, page still usable', !gate); + const txt = await page.evaluate(() => document.body.innerText); + t('the dashboard still renders for them', txt.length > 300, 'len ' + txt.length); +} + +const fatal = errs.filter(e => /PAGEERROR/.test(e)); +t('no uncaught page errors', fatal.length === 0, fatal.join(' | ')); + +console.log('[' + SCENARIO + '] PASS ' + ok.length); +for (const b of bad) console.log('[' + SCENARIO + '] FAIL ' + b); +if (errs.length) console.log(' console noise:', errs.slice(0, 3)); +await browser.close(); +process.exit(bad.length ? 1 : 0);