aboutsummaryrefslogtreecommitdiffstats
path: root/tools/account-delete-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/account-delete-test.js')
-rw-r--r--tools/account-delete-test.js115
1 files changed, 115 insertions, 0 deletions
diff --git a/tools/account-delete-test.js b/tools/account-delete-test.js
new file mode 100644
index 0000000..d06bcb8
--- /dev/null
+++ b/tools/account-delete-test.js
@@ -0,0 +1,115 @@
+// Settings → Wallet → Delete account: asks the registrar what stands in the
+// way, says what goes and what stays (and how many sats are still here),
+// and on a ticked box + tap has the registrar forget the account (a NIP-98
+// request signed by the wallet's key) and removes the wallet from the device.
+// The registrar here is a stand-in inside the test; relays are the stand-ins.
+//
+// Run: bun tools/account-delete-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { verifyEvent } from 'nostr-tools/pure';
+import { createHash } from 'node:crypto';
+import { buildHtml } from '../build.js';
+
+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; };
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5271, 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: 420, height: 900 });
+page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; });
+
+// the registrar: records what it's asked, answers as the real one would
+const calls = [];
+let owed = { pendingSat: 0, withdrawSat: 0 };
+const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' };
+await page.setRequestInterception(true);
+page.on('request', (req) => {
+ const u = new URL(req.url());
+ if (u.hostname !== 'names.coinos.io') return req.continue();
+ if (req.method() === 'OPTIONS') return req.respond({ status: 200, headers: cors, body: '{}' });
+ const reply = (status, j) => req.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) });
+ if (u.pathname !== '/account/delete') return reply(404, { error: 'not found' });
+ const body = req.postData() || '';
+ let ev = null;
+ try { ev = JSON.parse(Buffer.from((req.headers().authorization || '').replace(/^Nostr /, ''), 'base64').toString()); } catch {}
+ const tag = (k) => ev?.tags.find((t) => t[0] === k)?.[1];
+ const signed = !!ev && ev.kind === 27235 && verifyEvent(ev) && tag('method') === 'POST'
+ && new URL(tag('u')).pathname === '/account/delete' && tag('payload') === createHash('sha256').update(body).digest('hex');
+ const j = JSON.parse(body || '{}');
+ calls.push({ check: !!j.check, signed, pubkey: ev?.pubkey });
+ const blocked = owed.pendingSat + owed.withdrawSat > 0;
+ if (j.check) return reply(200, { names: ['[email protected]'], ...owed, paying: 0, blocked });
+ if (blocked) return reply(409, { error: 'funds', ...owed, paying: 0 });
+ return reply(200, { ok: true, names: ['[email protected]'] });
+});
+
+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 until = async (fn, ms = 15000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(250); } };
+const text = () => page.evaluate(() => document.body.innerText);
+const goBtn = () => page.evaluate(() => { const b = [...document.querySelectorAll('button.btn-danger')].find((x) => x.textContent.trim() === 'Delete account' && x.closest('.card')?.querySelector('input[type=checkbox]')); return b ? { disabled: b.disabled } : null; });
+const openCard = async () => {
+ await page.evaluate(() => document.querySelector('[data-key="delete-account"] button')?.click());
+ await until(async () => (await text()).includes('[email protected] is released'));
+};
+
+try {
+ await page.goto('http://localhost:5271/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ 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 until(async () => /receive/i.test(await text()));
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings')?.click());
+ await until(async () => (await text()).includes('Advanced'));
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Wallet'))?.click());
+ await sleep(500);
+ if (process.env.DEBUG) console.log((await text()).slice(0, 600));
+ check('Settings → Wallet has a Delete account card', !!(await until(() => page.evaluate(() => !!document.querySelector('[data-key="delete-account"]')))));
+
+ console.log('\n[sats still owed]');
+ owed = { pendingSat: 21, withdrawSat: 500 };
+ await openCard();
+ let tx = await text();
+ check('says the name is released, records deleted, and what stays', /is released/.test(tx) && /Our servers delete/.test(tx) && /Not deleted: your coins/.test(tx));
+ check('names the sats still owed and won\'t go on', /521 sats are still on their way/.test(tx) && (await goBtn())?.disabled === true, tx.match(/⚠[^\n]*/)?.[0]?.slice(0, 80));
+ check('the question was signed by the wallet\'s key', calls.length === 1 && calls[0].check && calls[0].signed);
+ await click('button', 'Back');
+ await sleep(300);
+ check('Back returns to the settings page, account intact', !!(await page.evaluate(() => !!document.querySelector('[data-key="delete-account"]'))) && calls.every((c) => c.check));
+
+ console.log('\n[nothing in the way]');
+ owed = { pendingSat: 0, withdrawSat: 0 };
+ await openCard();
+ check('the button waits for the box to be ticked', (await goBtn())?.disabled === true);
+ await page.evaluate(() => document.querySelector('.card input[type=checkbox]')?.click());
+ await sleep(300);
+ check('ticked: the button is live', (await goBtn())?.disabled === false);
+ const before = calls.length;
+ await page.evaluate(() => [...document.querySelectorAll('button.btn-danger')].find((x) => x.textContent.trim() === 'Delete account' && !x.disabled)?.click());
+ const gone = await until(async () => /create a new wallet/i.test(await text()), 20000);
+ if (process.env.DEBUG) console.log((await text()).slice(0, 900));
+ check('the registrar was told to delete, signed', calls.slice(before).some((c) => !c.check && c.signed), JSON.stringify(calls.slice(before).map((c) => ({ check: c.check, signed: c.signed }))));
+ check('the wallet is gone from the device: back at the front door', !!gone);
+ const left = await page.evaluate(() => ({ session: sessionStorage.getItem('btc-wallet-accounts'), vault: localStorage.getItem('btc-wallet-vault') }));
+ check('no account left in the session', !left.session || !JSON.parse(left.session).accounts?.length, String(left.session).slice(0, 60));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ check('and a reload doesn\'t bring it back', !!(await until(async () => /create a new wallet/i.test(await text()), 10000)));
+} catch (e) {
+ console.log(' error:', e.message); ok = false;
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\nPASS' : '\nFAIL');
+process.exit(ok ? 0 : 1);