aboutsummaryrefslogtreecommitdiffstats
path: root/tools/chat-remove-member-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/chat-remove-member-test.js')
-rw-r--r--tools/chat-remove-member-test.js224
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);