diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/chat-remove-member-test.js | 224 |
1 files changed, 224 insertions, 0 deletions
diff --git a/tools/chat-remove-member-test.js b/tools/chat-remove-member-test.js new file mode 100644 index 0000000..1007973 --- /dev/null +++ b/tools/chat-remove-member-test.js @@ -0,0 +1,224 @@ +// A community's admin taps a message and removes its author, with everything +// they said: one owner-signed ban-list edition (CORD-04 vsk 4) that every +// client folds. "can i have some control as a chat group admin to click on a +// message and see an option to remove the user and all their messages from +// the group?" (Adam, 2026-11-10). +// +// Everything runs against a relay inside this process: the page's sockets all +// go there, so nothing reaches a live room. +// +// Run: bun tools/chat-remove-member-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { + parseInviteLink, openInviteBundle, channelStream, channelEpoch, controlKey, wrapRumor, openWrap, msTags, + foldControl, banlistHead, makeBanlist, +} from '../src/concord.js'; +import { hexToBytes } from '@noble/hashes/utils'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +// ---- a relay in a few lines: stores what it's given, serves it by filter ---- +const events = new Map(); +const socks = new Map(); // ws -> Map(subId -> filters) +const matches = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) + && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until) + && Object.entries(f).every(([k, v]) => k[0] !== '#' || e.tags.some((t) => t[0] === k.slice(1) && v.includes(t[1]))); +const store = (e) => { + if (events.has(e.id)) return; + events.set(e.id, e); + for (const [ws, subs] of socks) for (const [id, fs] of subs) if (fs.some((f) => matches(f, e))) ws.send(JSON.stringify(['EVENT', id, e])); +}; +const RELAY_PORT = 27811, RELAY = `ws://127.0.0.1:${RELAY_PORT}`; +const relay = Bun.serve({ + port: RELAY_PORT, hostname: '127.0.0.1', + fetch(req, srv) { return srv.upgrade(req) ? undefined : new Response('relay'); }, + websocket: { + open(ws) { socks.set(ws, new Map()); }, + close(ws) { socks.delete(ws); }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { store(m[1]); ws.send(JSON.stringify(['OK', m[1].id, true, ''])); } + else if (m[0] === 'REQ') { + const fs = m.slice(2); + socks.get(ws).set(m[1], fs); + for (const f of fs) { + const hit = [...events.values()].filter((e) => matches(f, e)).sort((a, b) => b.created_at - a.created_at); + for (const e of f.limit ? hit.slice(0, f.limit) : hit) ws.send(JSON.stringify(['EVENT', m[1], e])); + } + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') socks.get(ws)?.delete(m[1]); + else if (m[0] === 'COUNT') ws.send(JSON.stringify(['COUNT', m[1], { count: 0 }])); + else if (/^NEG-/.test(m[0])) ws.send(JSON.stringify(['NEG-ERR', m[1], 'blocked: unsupported'])); + }, + }, +}); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5263, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 1100, height: 850 }); +page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; }); +await page.evaluateOnNewDocument((relayUrl) => { + const Real = window.WebSocket; + window.WebSocket = function (url, protocols) { return new Real(/^wss?:/.test(url) ? relayUrl : url, protocols); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + localStorage.setItem('btc-wallet-network', 'regtest'); + // the invite link is "copied": keep it where the test can read it + try { Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (v) => { window.__clip = v; }, readText: async () => window.__clip || '' }, configurable: true }); } catch {} +}, RELAY); + +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); +const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v); +const until = async (fn, ms = 20000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(300); } }; +const bodyText = () => page.evaluate(() => document.body.innerText); +const waitText = (x, ms = 20000) => until(async () => (await bodyText()).toLowerCase().includes(x.toLowerCase()), ms); +const logText = () => page.evaluate(() => document.querySelector('.chat-log')?.innerText || ''); +const say = async (text) => { + await page.waitForSelector('#msg-draft', { timeout: 15000 }); + await page.evaluate((v) => { + const e = document.querySelector('#msg-draft'); + const ta = e.shadowRoot ? e.shadowRoot.querySelector('textarea') : (e.querySelector && e.querySelector('textarea')) || e; + ta.focus(); ta.value = v; + ta.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + }, text); + await sleep(150); + await page.keyboard.press('Enter'); +}; +const tapBubble = (text) => page.evaluate((x) => { + const b = [...document.querySelectorAll('.chat-log .chat-bubble')].find((n) => n.innerText.includes(x)); + if (b) b.click(); + return !!b; +}, text); +const sheet = () => page.evaluate(() => [...document.querySelectorAll('.msg-sheet .msg-sheet-item')].map((b) => b.innerText.trim())); + +try { + await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'Create a new wallet'); + await sleep(300); + await click('button', 'Have an existing seed'); + await sleep(300); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + await waitText('receive', 15000); + await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click()); + await sleep(800); + await click('button', 'Create'); + await sleep(300); + await setInput('input[placeholder*="name" i]', 'removal test'); + await sleep(200); + await page.evaluate(() => { const i = document.querySelector('input[placeholder*="name" i]'); const b = i && i.parentElement.querySelector('button'); if (b) b.click(); }); + await waitText('removal test', 25000); + await click('.item', 'removal test'); + await page.waitForSelector('#msg-draft', { timeout: 15000 }); + await say('the admin says hello'); + check('the admin posts in their community', await until(async () => (await logText()).includes('the admin says hello'))); + + // the join material, the way anyone gets it: the invite link + await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click()); + await sleep(300); + await click('button', 'Copy invite link'); + const link = await until(() => page.evaluate(() => window.__clip || null), 10000); + const parsed = parseInviteLink(link || ''); + const bundleEv = parsed && [...events.values()].find((e) => e.kind === 33301 && e.pubkey === parsed.signerPk); + const jm = bundleEv && openInviteBundle(bundleEv, parsed.token); + check('invite link opens the community', !!(jm && jm.community_root), link ? link.slice(0, 50) + '…' : 'no link'); + if (!jm) throw new Error('no join material'); + await page.evaluate(() => document.querySelector('[aria-label="Add person"]')?.click()); + const ch = jm.channels[0]; + const stream = channelStream(jm, ch); + const post = async (sk, text) => { + const { created_at, ms } = msTags(Date.now()); + const rumor = { kind: 9, pubkey: getPublicKey(sk), content: text, tags: [['channel', ch.id], ['epoch', String(channelEpoch(jm, ch))], ms], created_at }; + store(await wrapRumor(rumor, sk, stream)); + }; + const spammer = generateSecretKey(), friend = generateSecretKey(); + await post(spammer, 'Can I have some free sats'); + await sleep(30); + await post(friend, 'good evening all'); + await sleep(30); + await post(spammer, 'Any donation can help'); + check('members\' messages arrive', await until(async () => { const x = await logText(); return x.includes('free sats') && x.includes('good evening') && x.includes('donation'); })); + + console.log('\n[the sheet]'); + await tapBubble('the admin says hello'); + await sleep(300); + let items = await sheet(); + check('own message: no remove option', items.length > 0 && !items.some((x) => /remove/i.test(x)), items.join(' / ')); + await page.keyboard.press('Escape'); + await page.evaluate(() => document.querySelector('.confirm-pop-backdrop')?.click()); + await sleep(300); + await tapBubble('free sats'); + await sleep(300); + items = await sheet(); + check('someone else\'s message: "Remove … and their messages"', items.some((x) => /Remove .+ and their messages$/.test(x)), items.join(' / ')); + await click('.msg-sheet-item', 'and their messages'); + await sleep(300); + items = await sheet(); + check('first tap only asks', items.some((x) => /tap again/i.test(x)) && (await logText()).includes('free sats'), items.join(' / ')); + await click('.msg-sheet-item', 'tap again'); + const gone = await until(async () => { const x = await logText(); return !x.includes('free sats') && !x.includes('donation'); }, 15000); + check('second tap: every message of theirs is gone', !!gone); + const after = await logText(); + check('everyone else\'s stay', after.includes('good evening') && after.includes('the admin says hello')); + + console.log('\n[what other clients see]'); + const control = controlKey(hexToBytes(jm.community_root), jm.community_id, jm.root_epoch || 0); + const entries = () => [...events.values()].filter((e) => e.kind === 1059 && e.pubkey === control.pk).map((w) => openWrap(w, control)).filter(Boolean); + let folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + check('the relay holds an owner-signed ban list naming them', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1); + // a member (not the owner) signing a higher version can't undo it + const head = banlistHead(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + const forged = { ...makeBanlist({ ...head, version: head.version + 5 }, [], Date.now()), pubkey: getPublicKey(spammer) }; + store(await wrapRumor(forged, spammer, control, { plaintext: true })); + folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + check('a member\'s own "empty list" edition changes nothing', folded.banned.has(getPublicKey(spammer))); + await post(spammer, 'me again, free sats?'); + await post(friend, 'still here'); + await until(async () => (await logText()).includes('still here')); + check('their new messages never show', !(await logText()).includes('me again')); + + console.log('\n[after a reload, and a second removal]'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await until(async () => (await logText()).includes('good evening'), 25000); + await sleep(1500); + check('still gone after a reload', !/free sats|donation|me again/.test(await logText()) && (await logText()).includes('good evening')); + const second = generateSecretKey(); + await post(second, 'buy my course'); + await until(async () => (await logText()).includes('buy my course')); + await tapBubble('buy my course'); + await sleep(300); + await click('.msg-sheet-item', 'and their messages'); + await sleep(300); + await click('.msg-sheet-item', 'tap again'); + await until(async () => !(await logText()).includes('buy my course'), 15000); + folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + check('the second removal keeps the first', folded.banned.has(getPublicKey(spammer)) && folded.banned.has(getPublicKey(second)) && folded.banned.size === 2, `${folded.banned.size} on the list`); + + console.log('\n[putting someone back]'); + await page.evaluate(() => document.querySelector('.chat-head .col.clickable')?.click()); + await sleep(500); + const hasList = await until(async () => (await bodyText()).includes('Removed from this community'), 5000); + check('the member list shows who was removed (to the admin)', !!hasList); + await page.evaluate(() => { const rows = [...document.querySelectorAll('.chat-members .item')].filter((r) => [...r.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Restore')); rows.at(-1)?.querySelector('button')?.click(); }); + const back = await until(async () => (await logText()).includes('buy my course'), 15000); + check('Restore brings their messages back', !!back); + folded = foldControl(entries(), { ownerHex: jm.owner, cid: jm.community_id }); + check('and the list keeps the other', folded.banned.has(getPublicKey(spammer)) && folded.banned.size === 1); +} catch (e) { + console.log(' error:', e.message); ok = false; +} finally { + await browser.close(); + server.stop(true); relay.stop(true); +} +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); |
