Oregami
Repositories/oxedyne/daimond

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.
15import { open, errors } from './harness.mjs';
16
17const ok = [], bad = [];
18const 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.
24const s = await open({ name: 'accounts', connect: false, signIn: false });
25const p = s.page;
26await p.waitForTimeout(1500);
27
28// ── One browser starts with exactly one account: the primary ────────────
29
30const 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});
34check('a browser starts with one account, the primary', start.count === 1 && start.primary === true);
35check('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.
44await 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});
62await p.reload({ waitUntil: 'domcontentloaded' });
63await p.waitForTimeout(1500);
64
65// ── Account A writes distinctive data ───────────────────────────────────
66
67const 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});
77const 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});
85check('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
90await p.evaluate(() => { window.DaimondAccounts.add('Bob'); });
91await p.reload({ waitUntil: 'domcontentloaded' });
92await p.waitForTimeout(1800);
93
94const 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});
98check('adding an account makes two, and lands on the new one', bState.count === 2 && bState.name === 'Bob');
99check('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.
103const 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});
111check('account B does NOT see A\'s chat', !bData.chats || !/A-secret-chat/.test(bData.chats), String(bData.chats));
112check('account B does NOT see A\'s provider key', !bData.key || !/KEY-FOR-A/.test(bData.key), String(bData.key));
113check('account B does NOT see A\'s workspace file', bData.file === 'not-found', bData.file);
114
115// B writes its own data.
116await 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
125await p.evaluate((id) => { window.DaimondAccounts.setCurrent(id); }, aId);
126await p.reload({ waitUntil: 'domcontentloaded' });
127await p.waitForTimeout(1800);
128
129const 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});
140check('switching back reaches account A again', backToA.current === '(primary)');
141check('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);
144check('and A cannot see B\'s file either', backToA.bFile === 'not-found', backToA.bFile);
145
146// ── The unlock picker shows both accounts ───────────────────────────────
147
148const 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});
154check('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
157const errs = errors(s).filter(e => !/favicon|404|401|502|Bad Gateway|net::ERR/.test(e));
158console.log('\nconsole errors:', errs.slice(0, 4));
159check('nothing throws through all the switching', errs.length === 0, errs[0] || '');
160
161await s.close();
162console.log(`\n${ok.length} passed, ${bad.length} failed`);
163if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
164process.exit(bad.length ? 1 : 0);