aboutsummaryrefslogtreecommitdiffstats
path: root/tools/lock-wallet-test.js
blob: 1c46f2bb5b104018d6844ad88608c4e3016cfb40 (plain) (blame)
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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
// "Lock wallet" locks; only "Clear all" deletes.
//
// Wallets save themselves to this device under an empty password unless the
// user sets one, so the unlock screen was demanding a password nobody had
// chosen — which made the action look like it had done something drastic. It
// hasn't: locking keeps every saved wallet.
//
// Run: bun tools/logout-test.js
import puppeteer from 'puppeteer-core';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
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 ? ' — ' + String(d).slice(0, 160) : ''}`); if (!c) ok = false; };

const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5235, 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();
const body = () => page.evaluate(() => document.body.innerText);
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 waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await body()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
const logout = async () => {
  await page.evaluate(() => document.querySelector('.header-avatar')?.click());
  await sleep(400);
  await click('.icon-select-item', 'Lock wallet');
  await sleep(1500);
};
const vaultSize = () => page.evaluate(() => (localStorage.getItem('btc-wallet-vault') || '').length);

try {
  await page.goto('http://localhost:5235/', { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await click('button', 'I already have a wallet');
  await sleep(300);
  await click('button', 'Import existing');
  await sleep(300);
  await page.waitForSelector('textarea');
  await page.type('textarea', generateMnemonic(wordlist));
  await click('button', 'Open wallet');
  check('wallet open', await waitText('receive', 15000));
  await sleep(2500);
  const savedBytes = await vaultSize();
  check('it saved itself to the device', savedBytes > 0, `${savedBytes} bytes of vault`);

  console.log('\n[locking with no password set]');
  await logout();
  let txt = await body();
  check('it offers a password once', /protect this device/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | '));
  check('and does not force one', /not now/i.test(txt));
  await click('button', 'Not now');
  await sleep(800);
  txt = await body();
  check('declining locks anyway', !/protect this device/i.test(txt));
  check('no password is demanded', !/enter your password|unlock saved wallets/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | '));
  check('the saved wallet is still there', (await vaultSize()) === savedBytes);
  check('and it is offered back', /wallet 1|wallets/i.test(txt), txt.slice(0, 140).replace(/\n+/g, ' | '));

  const listed = await page.evaluate(() => [...document.querySelectorAll('button, .item, .acct')].map((e) => e.textContent.trim()).filter((x) => /Wallet 1/.test(x)));
  check('and only once — no phantom duplicate', listed.length === 1, JSON.stringify(listed));

  console.log('\n[the offer is not a nag]');
  await page.evaluate(() => { const e = [...document.querySelectorAll('*')].find((n) => n.children.length === 0 && /Wallet 1/.test(n.textContent) && n.closest('button, .item, .acct')); if (e) e.closest('button, .item, .acct').click(); });
  await sleep(2000);
  await waitText('receive', 15000);
  await logout();
  check('it does not ask a second time', !/protect this device/i.test(await body()));

  console.log('\n[getting back in]');
  await page.evaluate(() => { const e = [...document.querySelectorAll('*')].find((n) => n.children.length === 0 && /Wallet 1/.test(n.textContent) && n.closest('button, .item, .acct')); if (e) e.closest('button, .item, .acct').click(); });
  await sleep(2000);
  check('one tap returns to the wallet', await waitText('receive', 15000));

  console.log('\n[now with a password]');
  // "Change password" lives on the Wallets screen, behind the header selector
  await page.evaluate(() => { const b = [...document.querySelectorAll('.row.between button')].find((e) => /Wallet/.test(e.textContent)); if (b) b.click(); });
  await sleep(1200);
  check('on the wallets screen', /wallets/i.test(await body()));
  await click('button', 'Change password');
  await sleep(1000);
  const fields = await page.$$('input[type=password]');
  check('the change-password form is up', fields.length >= 3, `${fields.length} field(s)`);
  // current (empty) / new / confirm
  await fields[fields.length - 2].type('hunter22');
  await fields[fields.length - 1].type('hunter22');
  await click('button', 'Save');
  const pwSet = await waitText('password changed', 8000);
  check('a password is actually set', pwSet, (await body()).slice(0, 120).replace(/\n+/g, ' | '));

  // back into the wallet — the Wallets screen has no avatar menu to log out from
  await click('button', 'Back');
  await sleep(1500);
  check('back in the wallet', await waitText('receive', 15000));
  await logout();
  txt = await body();
  check('now it does ask', /unlock saved wallets|enter your password/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | '));
  check('the wallet is still saved', (await vaultSize()) > 0);

  console.log('\n[clear all is the destructive one]');
  await click('button', 'Use another wallet');
  await sleep(1500);
  await page.evaluate(() => { const b = [...document.querySelectorAll('.row.between button')].find((e) => /Wallet/.test(e.textContent)); if (b) b.click(); });
  await sleep(1200);
  await click('button', 'Clear all');
  await sleep(800);
  const txt2 = await body();
  check('it warns before wiping', /removes every wallet/i.test(txt2), txt2.slice(0, 140).replace(/\n+/g, ' | '));
  check('and it has not wiped anything yet', (await vaultSize()) > 0);
  // A clean profile, because the offer is once-per-device and the run above
  // already answered it.
  console.log('\n[taking the offer, on a fresh device]');
  const ctx = await browser.createBrowserContext();
  const p2 = await ctx.newPage();
  const body2 = () => p2.evaluate(() => document.body.innerText);
  const click2 = (sel, x) => p2.evaluate((s, t) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(t.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, x);
  const wait2 = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await body2()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
  await p2.goto('http://localhost:5235/', { waitUntil: 'domcontentloaded' });
  await p2.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await p2.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await click2('button', 'I already have a wallet');
  await sleep(300);
  await click2('button', 'Import existing');
  await sleep(300);
  await p2.waitForSelector('textarea');
  await p2.type('textarea', generateMnemonic(wordlist));
  await click2('button', 'Open wallet');
  await wait2('receive', 15000);
  await sleep(2500);
  await p2.evaluate(() => document.querySelector('.header-avatar')?.click());
  await sleep(400);
  await click2('.icon-select-item', 'Lock wallet');
  await sleep(1200);
  check('offered on this device too', /protect this device/i.test(await body2()));

  const f2 = await p2.$$('input[type=password]');
  check('a new password and a confirmation', f2.length === 2, `${f2.length} field(s)`);
  await f2[0].type('correct horse');
  await f2[1].type('correct horse');
  await click2('button', 'Save');
  await sleep(1500);
  check('accepting it locks for real', /unlock saved wallets/i.test(await body2()), (await body2()).slice(0, 110).replace(/\n+/g, ' | '));

  const wrong = await p2.$('input[type=password]');
  await wrong.type('not the password');
  await click2('button', 'Unlock');
  await sleep(800);
  check('the wrong password is refused', /wrong password/i.test(await body2()));
  await p2.evaluate(() => { const i = document.querySelector('input[type=password]'); if (i) { i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); } });
  const right = await p2.$('input[type=password]');
  await right.type('correct horse');
  await click2('button', 'Unlock');
  check('the right one opens it', await wait2('receive', 15000));
  await ctx.close();
} catch (e) {
  check('run completed', false, e.message);
} finally {
  await browser.close();
  server.stop(true);
}
console.log(ok ? '\n✅ locking keeps your wallets' : '\n❌ failures above');
process.exit(ok ? 0 : 1);