diff options
Diffstat (limited to 'tools/account-delete-test.js')
| -rw-r--r-- | tools/account-delete-test.js | 115 |
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); |
