diff --git a/profiles.js b/profiles.js index 0cb3a55..abffbbd 100644 --- a/profiles.js +++ b/profiles.js @@ -168,5 +168,8 @@ function coverage() { } function adminList() { return Object.values(db.byId).map(pub).sort((a, b) => a.id - b.id); } -module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, +// local testing only: the server exposes this outside production, never on the live site +function peekCode(id) { const st = codes.get(norm(id)); return st ? st.code : null; } + +module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE }; diff --git a/public/my.html b/public/my.html index cf2bf64..2fd125f 100644 --- a/public/my.html +++ b/public/my.html @@ -31,5 +31,5 @@ - + diff --git a/public/profile-gate.js b/public/profile-gate.js index 3fd69ec..ae1fa72 100644 --- a/public/profile-gate.js +++ b/public/profile-gate.js @@ -38,6 +38,7 @@ if (editable) { // editing an existing profile can be abandoned; the first-time gate can not var x = el('button', 'position:absolute;top:10px;right:12px;z-index:2;width:34px;height:34px;border-radius:50%;border:none;' + 'cursor:pointer;background:rgba(255,255,255,.08);color:#fff;font-size:20px;line-height:1;font-weight:700;', '×'); + x.id = 'pgClose'; x.setAttribute('aria-label', 'Close'); x.addEventListener('click', close); card.appendChild(x); @@ -68,9 +69,10 @@ 'background:rgba(255,255,255,.04);color:#fff;font-size:16px;margin-bottom:10px;'; return i; } - function button(label) { + function button(label, id) { var b = el('button', 'width:100%;padding:13px 16px;border-radius:11px;border:none;cursor:pointer;font-size:15px;font-weight:700;' + 'background:linear-gradient(180deg,' + GOLD + ',#b8932f);color:#1a1205;', label); + if (id) b.id = id; return b; } function errLine() { return el('p', 'margin:0 0 10px;font-size:13px;color:#ff8b8b;display:none;'); } @@ -83,7 +85,7 @@ var err = errLine(); card.appendChild(err); var i = input('pgUser', 'e.g. ' + (state.suggest || 'member' + state.id), (state.profile && state.profile.username) || ''); card.appendChild(i); - var b = button('Save and continue'); + var b = button('Save and continue', 'pgUserSave'); card.appendChild(b); var go = async function () { err.style.display = 'none'; b.disabled = true; b.textContent = 'Saving…'; @@ -112,14 +114,14 @@ var err = errLine(); card.appendChild(err); var i = input('pgEmail', 'you@example.com', prefill, 'email'); card.appendChild(i); - var b = button(prefill ? 'Send me the code' : 'Send me a code'); + var b = button(prefill ? 'Send me the code' : 'Send me a code', 'pgMailSend'); card.appendChild(b); var codeWrap = el('div', 'display:none;margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,.1);'); var sentNote = note('', TEAL); codeWrap.appendChild(sentNote); var ci = input('pgCode', '6-digit code', ''); ci.inputMode = 'numeric'; ci.maxLength = 6; codeWrap.appendChild(ci); - var cb = button('Confirm and finish'); + var cb = button('Confirm and finish', 'pgCodeConfirm'); codeWrap.appendChild(cb); var again = el('p', 'margin:10px 0 0;font-size:12.5px;color:#7f93a8;text-align:center;cursor:pointer;', 'Wrong address? Change it and send a new code.'); again.addEventListener('click', function () { codeWrap.style.display = 'none'; b.disabled = false; b.textContent = 'Send me a code'; i.focus(); }); @@ -159,14 +161,15 @@ '
✅
' + '

You are all set, @' + esc(state.profile.username) + '

' + '

Your leader can reach you now, and every payout to your wallet sends you a note.

'; - var b = button('Open my dashboard'); - b.addEventListener('click', close); + var b = button('Open my dashboard', 'pgDone'); + b.addEventListener('click', function () { close(true); }); card.appendChild(b); - setTimeout(close, 2600); + setTimeout(function () { close(true); }, 2600); } - function close() { + function close(openDash) { if (back && back.parentNode) back.parentNode.removeChild(back); back = null; + if (openDash) { try { var b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); } catch (e) {} } if (resolveDone) { var r = resolveDone; resolveDone = null; r((state && state.profile) || null); } } function next() { diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000..ecc43da --- /dev/null +++ b/qa/README.md @@ -0,0 +1,25 @@ +# RM Circle QA + +## Member profile gate + +``` +# unit: profiles.js in isolation (seeding, username rules, code flow, persistence) +node qa/profiles-unit.mjs + +# end to end: the real UI with real inbox sessions +TMP=/d/tmp/rmc-e2e-data && rm -rf $TMP && mkdir -p $TMP +ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $TMP/config.json +echo '{}' > $TMP/sponsors.json +echo '{"49":{"email":"seeded49@example.com","ts":"x"}}' > $TMP/member-alerts.json +node -e "const m=require('./messages.js'); m.init({dataDir:process.argv[1],chain:require('./chain.js'),isProd:false}); require('fs').writeFileSync(process.argv[1]+'/tokens.txt', m.mintSession(21)+'\n'+m.mintSession(49));" $TMP +PORT=3399 DATA_DIR=$TMP ADMIN_PASSWORD=localtest node server.js & +LOCAL=http://127.0.0.1:3399 TOKEN=$(sed -n 1p $TMP/tokens.txt) TOKEN2=$(sed -n 2p $TMP/tokens.txt) node qa/gate-e2e.mjs +``` + +Covers: the gate fires on the owner's own page only, cannot be dismissed (Escape, backdrop, no close +button), username and email validation, the code flow including a wrong code, completion landing on +the dashboard tab, the profile card and in-place editing (dismissable), the seeded email pre-filling +at step 2, visitors on every ID-keyed shared link seeing no gate and no 401, and phone-width layout. + +`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. diff --git a/qa/gate-e2e.mjs b/qa/gate-e2e.mjs new file mode 100644 index 0000000..e43aff2 --- /dev/null +++ b/qa/gate-e2e.mjs @@ -0,0 +1,128 @@ +// End-to-end QA of the RM Circle required profile gate, driven through the real UI +// with a real inbox session. Boot: LOCAL (default :3399), session cookie in COOKIE. +import { pathToFileURL } from 'node:url'; +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 TOKEN = process.env.TOKEN; // ctb.msid for position 21 +const TOKEN2 = process.env.TOKEN2; // ctb.msid for position 49 (already complete) +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; +const browser = await chromium.launch(); +const ctxFor = async (tok, mobile) => { + const c = await browser.newContext(mobile ? { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } : { viewport: { width: 1280, height: 950 } }); + if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]); + return c; +}; +const gateOpen = p => p.evaluate(() => !!document.querySelector('#pgCard')); +const cardText = p => p.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); + +// ---------- 1. the gate on the member's OWN page ---------- +const ctx = await ctxFor(TOKEN); +const p = await ctx.newPage(); +await p.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p.waitForTimeout(2500); +t('gate appears on the owner\'s own page', await gateOpen(p)); +t('gate names the position and step', /position #21 . step 1 of 2/i.test(await cardText(p)), await cardText(p)); + +// cannot be dismissed +await p.keyboard.press('Escape'); await p.waitForTimeout(400); +t('Escape does not dismiss the required gate', await gateOpen(p)); +await p.mouse.click(8, 8); await p.waitForTimeout(400); +t('backdrop click does not dismiss the required gate', await gateOpen(p)); +t('no close button on the required gate', !(await p.evaluate(() => !!document.querySelector('#pgClose')))); + +// username validation +const typeUser = async v => { await p.fill('#pgUser', v); await p.click('#pgUserSave'); await p.waitForTimeout(600); }; +await typeUser('ab'); +t('rejects a short username', /3 to 20/.test(await cardText(p)), await cardText(p)); +await typeUser('12345'); +t('rejects digits only', /at least one letter/.test(await cardText(p))); +await typeUser('admin'); +t('rejects a reserved name', /reserved/.test(await cardText(p))); +await typeUser('takenname'); +t('accepts a good username and moves to step 2', /step 2 of 2/i.test(await cardText(p)), await cardText(p)); + +// email step +t('step 2 explains why and promises privacy', /never shown to other members/i.test(await cardText(p))); +await p.fill('#pgEmail', 'not-an-email'); await p.click('#pgMailSend'); await p.waitForTimeout(700); +t('rejects a malformed email', /does not look right/.test(await cardText(p)), await cardText(p)); +// grab the dev code straight off the API response +let devCode = null; +p.on('response', async r => { if (r.url().includes('/profile/email-start')) { try { const j = await r.json(); if (j.devCode) devCode = j.devCode; } catch (e) {} } }); +await p.fill('#pgEmail', 'qa-member@example.com'); await p.click('#pgMailSend'); await p.waitForTimeout(1400); +t('code is sent and the code box appears', /Code sent to qa\*\*\*@example\.com/.test(await cardText(p)), await cardText(p)); +t('the reply masks the address', /qa\*\*\*@example\.com/.test(await cardText(p))); +await p.fill('#pgCode', '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(800); +t('wrong code refused', /does not match/.test(await cardText(p)), await cardText(p)); +t('dev code exposed locally for the test', !!devCode, String(devCode)); +await p.fill('#pgCode', devCode || '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(1200); +t('right code completes the profile', /all set, @takenname/i.test(await cardText(p)), await cardText(p)); +await p.waitForTimeout(3200); +t('gate closes itself after completion', !(await gateOpen(p))); + +// ---------- 2. the profile card, and editing ---------- +t('completion lands on the dashboard tab', await p.evaluate(() => { const d = document.getElementById('tabDash'); return !!d && getComputedStyle(d).display !== 'none'; })); +await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2000); +await p.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p.waitForTimeout(1800); +t('no gate on a completed profile', !(await gateOpen(p))); +const body = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +t('dashboard shows the profile card', /Your member profile/.test(body), body.slice(0, 120)); +t('card shows the username', /@takenname/.test(body)); +t('card shows the confirmed email', /qa-member@example\.com/.test(body) && /confirmed/i.test(body)); +t('Change username button present', await p.evaluate(() => !!document.querySelector('#pgEditUser'))); +t('Change email button present', await p.evaluate(() => !!document.querySelector('#pgEditMail'))); +await p.click('#pgEditUser'); await p.waitForTimeout(900); +t('edit modal opens', await gateOpen(p)); +t('edit modal HAS a close button', await p.evaluate(() => !!document.querySelector('#pgClose'))); +await p.click('#pgClose'); await p.waitForTimeout(600); +t('edit modal can be dismissed', !(await gateOpen(p))); +await p.click('#pgEditUser'); await p.waitForTimeout(800); +await p.fill('#pgUser', 'renamedqa'); +await p.click('#pgUserSave'); await p.waitForTimeout(1400); +t('rename saves and closes', !(await gateOpen(p))); +await p.waitForTimeout(1200); +t('card shows the new username', /@renamedqa/.test(await p.evaluate(() => document.body.innerText))); + +// ---------- 3. scoping: never on someone else's page ---------- +const p2 = await ctx.newPage(); +await p2.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p2.waitForTimeout(2000); +await p2.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p2.waitForTimeout(1600); +t('no gate while browsing another position', !(await gateOpen(p2))); +t('no profile card on another position\'s page', !/Your member profile/.test(await p2.evaluate(() => document.body.innerText))); +t('the other page still renders', (await p2.evaluate(() => document.body.innerText)).length > 400); + +// an INCOMPLETE member browsing someone else's page must also not be gated +const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage(); +await p3.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2600); +t('incomplete member is not gated on a teammate page', !(await gateOpen(p3))); +await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2600); +t('incomplete member IS gated on their own page', await gateOpen(p3)); +t('their gate starts at the username step', /step 1 of 2/i.test(await cardText(p3)), await cardText(p3)); +await p3.fill('#pgUser', 'seeded49'); await p3.click('#pgUserSave'); await p3.waitForTimeout(900); +t('seeded email is pre-filled at step 2', await p3.evaluate(() => { const i = document.querySelector('#pgEmail'); return i ? i.value : ''; }) === 'seeded49@example.com', await p3.evaluate(() => { const i = document.querySelector('#pgEmail'); return i ? i.value : 'no input'; })); +t('copy tells them it is already on file', /already have this one on file/i.test(await cardText(p3)), await cardText(p3)); + +// ---------- 4. visitors ---------- +const ctxAnon = await ctxFor(null); const p4 = await ctxAnon.newPage(); +const anon401 = []; +p4.on('response', r => { if (r.status() === 401 && r.url().includes('/profile')) anon401.push(r.url()); }); +for (const u of ['/my/21', '/my/49', '/join/21', '/fast-start?id=21', '/generation-pay?id=21', '/flyers?id=21']) { + await p4.goto(B + u, { waitUntil: 'domcontentloaded' }); await p4.waitForTimeout(1600); + const txt = await p4.evaluate(() => document.body.innerText); + t('visitor: ' + u + ' renders with no gate', !(await gateOpen(p4)) && txt.length > 300, 'len ' + txt.length); +} +t('visitor sees no 401 from the profile endpoint', anon401.length === 0, anon401.join(',')); + +// ---------- 5. phone width ---------- +const ctxM = await ctxFor(TOKEN2, true); const p5 = await ctxM.newPage(); +await p5.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p5.waitForTimeout(2600); +t('gate renders on a phone', await gateOpen(p5)); +const fits = await p5.evaluate(() => { const c = document.querySelector('#pgCard'); if (!c) return false; const r = c.getBoundingClientRect(); return r.width <= window.innerWidth && r.left >= 0; }); +t('gate fits the phone viewport', fits); +const noHScroll = await p5.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 2); +t('no horizontal scroll on a phone', noHScroll); + +console.log('\nPASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +await browser.close(); +process.exit(bad.length ? 1 : 0); diff --git a/qa/profiles-unit.mjs b/qa/profiles-unit.mjs new file mode 100644 index 0000000..97eae02 --- /dev/null +++ b/qa/profiles-unit.mjs @@ -0,0 +1,62 @@ +// Unit test for RM Circle profiles.js: seeding, username rules, email code flow, coverage. +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +const require = createRequire('D:/Projects/HighRisk/The RM Circle/promos/package.json'); +const DIR = 'D:/tmp/rmc-prof-data'; +fs.rmSync(DIR, { recursive: true, force: true }); fs.mkdirSync(DIR, { recursive: true }); +// two positions already gave an email for payout alerts: they must seed in, unverified +fs.writeFileSync(DIR + '/member-alerts.json', JSON.stringify({ '21': { email: 'Marty@Example.com', ts: 'x' }, '49': { email: 'b@example.com', ts: 'x' } })); +const profiles = require('D:/Projects/HighRisk/The RM Circle/promos/profiles.js'); +const sent = []; +profiles.init({ dataDir: DIR, sendEmail: (to, subj, text) => sent.push({ to, subj, text }) }); +const ok = [], bad = []; +const t = (name, cond, extra) => (cond ? ok : bad).push(name + (extra ? ' -> ' + extra : '')); + +// seeding +t('seeded 2 alert emails', profiles.coverage().withEmail === 2, JSON.stringify(profiles.coverage())); +t('seeded email is lowercased', (profiles.get(21) || {}).email === 'marty@example.com'); +t('seeded email is NOT verified', !(profiles.get(21) || {}).emailVerified); +t('seeded position is incomplete', !profiles.complete(21)); + +// username rules +t('rejects short username', !!profiles.setUsername(21, 'ab').error); +t('rejects digits-only', !!profiles.setUsername(21, '12345').error); +t('rejects reserved', !!profiles.setUsername(21, 'admin').error); +t('rejects bad chars', !!profiles.setUsername(21, 'marty bostick').error); +t('accepts good username', profiles.setUsername(21, 'MartyB').ok === true); +t('username is lowercased', (profiles.get(21) || {}).username === 'martyb'); +t('strips a leading @', profiles.setUsername(49, '@circleguy').ok && profiles.get(49).username === 'circleguy'); +t('blocks a taken username', !!profiles.setUsername(49, 'martyb').error); +t('lets the same position keep its own name', profiles.setUsername(21, 'martyb').ok === true); +t('byUsername finds it', (profiles.byUsername('@MartyB') || {}).id === 21); + +// email flow +t('rejects a bad address', !!profiles.startEmail(21, 'not-an-email').error); +const s1 = profiles.startEmail(21, 'marty@example.com'); +t('sends a code', s1.ok === true && sent.length === 1, JSON.stringify(s1)); +t('code email masks the address in the reply', (s1.to || '').includes('***')); +const code = (sent[0].subj.match(/(\d{6})/) || [])[1]; +t('subject carries a 6-digit code', !!code); +t('cooldown blocks an instant resend', !!profiles.startEmail(21, 'marty@example.com').error); +t('wrong code refused', !!profiles.verifyEmail(21, '000000').error); +t('right code verifies', profiles.verifyEmail(21, code).ok === true); +t('profile now complete', profiles.complete(21) === true); +t('contactFor reports reachable', profiles.contactFor(21).reachable === true); +t('unverified position is NOT reachable', profiles.contactFor(49).reachable === false); +t('replayed code is refused', !!profiles.verifyEmail(21, code).error); + +// one email, several positions (Triple Play) +profiles.setUsername(137, 'martyb2'); +const s2 = profiles.startEmail(137, 'marty@example.com'); +const code2 = (sent[sent.length - 1].subj.match(/(\d{6})/) || [])[1]; +profiles.verifyEmail(137, code2); +t('one email can hold several positions', JSON.stringify(profiles.positionsFor('marty@example.com')) === '[21,137]', JSON.stringify(profiles.positionsFor('marty@example.com'))); + +// persistence +const fresh = JSON.parse(fs.readFileSync(DIR + '/profiles.json', 'utf8')); +t('written to disk', fresh.byId['21'].emailVerified === true); +t('coverage counts', profiles.coverage().complete === 2, JSON.stringify(profiles.coverage())); + +console.log('PASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +process.exit(bad.length ? 1 : 0); diff --git a/server.js b/server.js index 828b947..6a6015c 100644 --- a/server.js +++ b/server.js @@ -1466,6 +1466,7 @@ async function handleApi(req,res,pathname){ if(!s)return json(res,401,{error:'Not signed in.'}); const b=await bodyJson(req).catch(()=>null); const r=profiles.startEmail(s.id,b&&b.email); + if(r.ok&&!IS_PROD&&r.devCode===undefined)r.devCode=profiles.peekCode(s.id); // local testing only; never in production return json(res,r.error?400:200,r); } if(req.method==='POST'&&pathname==='/api/public/profile/email-verify'){