1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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);
|