oxedyne/daimond/dev/verify_accounts.mjs
7.9 KiB, 1 run
created by r2519314175:223, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_accounts.mjs — several people, one browser, and none of them sees the others. |
| 2 | // |
| 3 | // The promise is isolation: account B must not see account A's chats, provider keys, credits |
| 4 | // ledger, or workspace files, and switching back to A must find A's data intact. The test drives |
| 5 | // the real storage layer — the localStorage shim in accounts.js and the OPFS namespace in the |
| 6 | // wasm — and checks the two directly: |
| 7 | // |
| 8 | // * localStorage: write a marker under A, switch to B, confirm it is gone; confirm B's own |
| 9 | // marker does not bleed back to A. |
| 10 | // * OPFS: write a file through the wasm's own write_file under A, switch to B, confirm B's |
| 11 | // read_file cannot find it and lists a different root. |
| 12 | // |
| 13 | // The primary account keeps the raw keys and the OPFS root (so an existing install is untouched); |
| 14 | // only a SECOND account brings a namespace into being. Both halves are checked. |
| 15 | import { open, errors } from './harness.mjs'; |
| 16 | |
| 17 | const ok = [], bad = []; |
| 18 | const check = (name, pass, detail) => { |
| 19 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 20 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 21 | }; |
| 22 | |
| 23 | // signIn:false — we drive the identity/account machinery ourselves. |
| 24 | const s = await open({ name: 'accounts', connect: false, signIn: false }); |
| 25 | const p = s.page; |
| 26 | await p.waitForTimeout(1500); |
| 27 | |
| 28 | // ── One browser starts with exactly one account: the primary ──────────── |
| 29 | |
| 30 | const start = await p.evaluate(() => { |
| 31 | const A = window.DaimondAccounts; |
| 32 | return { count: A.list().length, primary: A.account().primary, prefix: A.prefix(), opfsNs: A.opfsNs() }; |
| 33 | }); |
| 34 | check('a browser starts with one account, the primary', start.count === 1 && start.primary === true); |
| 35 | check('and the primary is un-namespaced — raw keys, OPFS root', |
| 36 | start.prefix === '' && start.opfsNs === '', `prefix="${start.prefix}" ns="${start.opfsNs}"`); |
| 37 | |
| 38 | // Chats live in IndexedDB now, per account — `daimond-chats` for the primary and |
| 39 | // `daimond-chats-<ns>` for the rest — and the old localStorage key is CONSUMED into |
| 40 | // it on the first boot that finds one. So a seed written to localStorage here is in |
| 41 | // localStorage until the next reload and in the store afterwards, and a test about |
| 42 | // isolation has to look in both. The DB name is the isolation: reading the wrong |
| 43 | // one is the failure this file exists to catch. |
| 44 | await p.addInitScript(() => { |
| 45 | window.__chats = async () => { |
| 46 | const ns = (window.DaimondAccounts && DaimondAccounts.opfsNs()) || ''; |
| 47 | const rows = await new Promise((res) => { |
| 48 | const req = indexedDB.open('daimond-chats' + (ns ? '-' + ns : ''), 1); |
| 49 | req.onsuccess = () => { |
| 50 | const db = req.result; |
| 51 | let t; |
| 52 | try { t = db.transaction('chats', 'readonly'); } catch (e) { res([]); return; } |
| 53 | const all = t.objectStore('chats').getAll(); |
| 54 | all.onsuccess = () => res(all.result || []); |
| 55 | all.onerror = () => res([]); |
| 56 | }; |
| 57 | req.onerror = () => res([]); |
| 58 | }); |
| 59 | return JSON.stringify(rows) + ' ' + (localStorage.getItem('daimond-chats') || ''); |
| 60 | }; |
| 61 | }); |
| 62 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 63 | await p.waitForTimeout(1500); |
| 64 | |
| 65 | // ── Account A writes distinctive data ─────────────────────────────────── |
| 66 | |
| 67 | const aId = await p.evaluate(async () => { |
| 68 | const A = window.DaimondAccounts; |
| 69 | // Sensitive data in localStorage: a chat, a provider key. |
| 70 | localStorage.setItem('daimond-chats', JSON.stringify([{ id: 'cA', name: 'A-secret-chat' }])); |
| 71 | localStorage.setItem('daimond-byok', JSON.stringify({ apiKey: 'KEY-FOR-A' })); |
| 72 | // A workspace file, written through the wasm's own OPFS edge. |
| 73 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 74 | await mod.write_file('a-file.txt', 'A-workspace-secret'); |
| 75 | return A.current(); |
| 76 | }); |
| 77 | const aData = await p.evaluate(async () => { |
| 78 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 79 | return { |
| 80 | chats: await window.__chats(), |
| 81 | key: localStorage.getItem('daimond-byok'), |
| 82 | file: await mod.read_file('a-file.txt').catch(() => '(unreadable)'), |
| 83 | }; |
| 84 | }); |
| 85 | check('account A sees its own chat, key and file', |
| 86 | /A-secret-chat/.test(aData.chats) && /KEY-FOR-A/.test(aData.key) && aData.file === 'A-workspace-secret'); |
| 87 | |
| 88 | // ── Add account B, and switch to it (a reload, as the app does) ────────── |
| 89 | |
| 90 | await p.evaluate(() => { window.DaimondAccounts.add('Bob'); }); |
| 91 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 92 | await p.waitForTimeout(1800); |
| 93 | |
| 94 | const bState = await p.evaluate(() => { |
| 95 | const A = window.DaimondAccounts; |
| 96 | return { count: A.list().length, primary: A.account().primary, prefix: A.prefix(), opfsNs: A.opfsNs(), name: A.account().name }; |
| 97 | }); |
| 98 | check('adding an account makes two, and lands on the new one', bState.count === 2 && bState.name === 'Bob'); |
| 99 | check('the second account IS namespaced — its own prefix and OPFS subdir', |
| 100 | bState.prefix !== '' && bState.opfsNs !== '', `prefix="${bState.prefix}" ns="${bState.opfsNs}"`); |
| 101 | |
| 102 | // The whole point: B must see none of A's data. |
| 103 | const bData = await p.evaluate(async () => { |
| 104 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 105 | return { |
| 106 | chats: await window.__chats(), |
| 107 | key: localStorage.getItem('daimond-byok'), |
| 108 | file: await mod.read_file('a-file.txt').then(() => 'FOUND-A-FILE').catch(() => 'not-found'), |
| 109 | }; |
| 110 | }); |
| 111 | check('account B does NOT see A\'s chat', !bData.chats || !/A-secret-chat/.test(bData.chats), String(bData.chats)); |
| 112 | check('account B does NOT see A\'s provider key', !bData.key || !/KEY-FOR-A/.test(bData.key), String(bData.key)); |
| 113 | check('account B does NOT see A\'s workspace file', bData.file === 'not-found', bData.file); |
| 114 | |
| 115 | // B writes its own data. |
| 116 | await p.evaluate(async () => { |
| 117 | localStorage.setItem('daimond-chats', JSON.stringify([{ id: 'cB', name: 'B-secret-chat' }])); |
| 118 | localStorage.setItem('daimond-byok', JSON.stringify({ apiKey: 'KEY-FOR-B' })); |
| 119 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 120 | await mod.write_file('b-file.txt', 'B-workspace-secret'); |
| 121 | }); |
| 122 | |
| 123 | // ── Switch back to A: its data intact, B's invisible ──────────────────── |
| 124 | |
| 125 | await p.evaluate((id) => { window.DaimondAccounts.setCurrent(id); }, aId); |
| 126 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 127 | await p.waitForTimeout(1800); |
| 128 | |
| 129 | const backToA = await p.evaluate(async () => { |
| 130 | const A = window.DaimondAccounts; |
| 131 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 132 | return { |
| 133 | current: A.account().name || '(primary)', |
| 134 | chats: await window.__chats(), |
| 135 | key: localStorage.getItem('daimond-byok'), |
| 136 | aFile: await mod.read_file('a-file.txt').catch(() => '(gone)'), |
| 137 | bFile: await mod.read_file('b-file.txt').then(() => 'FOUND-B').catch(() => 'not-found'), |
| 138 | }; |
| 139 | }); |
| 140 | check('switching back reaches account A again', backToA.current === '(primary)'); |
| 141 | check('account A\'s chat, key and file survived the round trip', |
| 142 | /A-secret-chat/.test(backToA.chats) && /KEY-FOR-A/.test(backToA.key) && backToA.aFile === 'A-workspace-secret', |
| 143 | backToA.aFile); |
| 144 | check('and A cannot see B\'s file either', backToA.bFile === 'not-found', backToA.bFile); |
| 145 | |
| 146 | // ── The unlock picker shows both accounts ─────────────────────────────── |
| 147 | |
| 148 | const picker = await p.evaluate(() => { |
| 149 | // Force the identity modal to render in unlock mode via the app's own path is heavy; instead |
| 150 | // confirm the registry the picker reads from names both, with B nameable without unlocking. |
| 151 | const A = window.DaimondAccounts; |
| 152 | return A.list().map(a => a.name || '(primary, unnamed)'); |
| 153 | }); |
| 154 | check('the registry names both accounts, so the picker can list them without unlocking', |
| 155 | picker.length === 2 && picker.some(n => n === 'Bob'), picker.join(', ')); |
| 156 | |
| 157 | const errs = errors(s).filter(e => !/favicon|404|401|502|Bad Gateway|net::ERR/.test(e)); |
| 158 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 159 | check('nothing throws through all the switching', errs.length === 0, errs[0] || ''); |
| 160 | |
| 161 | await s.close(); |
| 162 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 163 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 164 | process.exit(bad.length ? 1 : 0); |