Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_mailsync.mjs

18.8 KiB, 1 run

created by r2519314175:527, 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_mailsync.mjs — a mailbox configured on one device works on the other.
2//
3// Sync carried the chats, the Diamonds, the workspace and the provider keys, and
4// left the mail behind: a paired device showed an empty Mail panel and the user
5// had to find their app password again and type the whole account back in. The
6// accounts travel now, WORKING — the wrapped password goes with them, because
7// both devices hold the same identity and what opens here opens there, and the
8// parcel is sealed over the top of it exactly as the sealed provider keys are.
9//
10// What must NOT travel is the per-folder state. Every UID, uidvalidity and
11// watermark under `folders` describes what is on THIS device's disk; carried
12// across it would tell the other device it already holds mail it has never
13// downloaded. It is rebuilt in one sync per folder and cannot be wrong.
14//
15// And a deletion has to travel as a DELETION. The merge is a union, so a mailbox
16// removed here and still held there comes straight back on the next pull, with
17// its password, and the seat given up at the gateway is taken again. That is what
18// the tombstone is for.
19//
20// node dev/verify_mailsync.mjs
21//
22// Needs the app (DAIMOND_PORT, default 8777) and the gateway on :9002 (sync is Pro-
23// gated, so the account is granted Pro the one way the gateway grants it).
24import { open, scratch } from './harness.mjs';
25import { makePagePro } from './pro.mjs';
26import path from 'node:path';
27import { fileURLToPath } from 'node:url';
28
29const ok = [], bad = [];
30const check = (name, pass, detail) => {
31 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
32 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
33};
34
35/// Push, and wait until the parcel has actually LANDED. A push that finds
36/// another in flight only reschedules, so awaiting it proves nothing; the server
37/// version advancing is the only honest signal one made it.
38async function pushLanded(pg) {
39 const landed = await pg.evaluate(async () => {
40 const v0 = window.DaimondSync.state().version;
41 const t0 = Date.now();
42 while (window.DaimondSync.state().version <= v0 && Date.now() - t0 < 8000) {
43 await window.DaimondSync.push();
44 await new Promise(r => setTimeout(r, 150));
45 }
46 return window.DaimondSync.state().version > v0;
47 });
48 if (!landed) console.log(' note pushLanded: version did not advance within 8s');
49 return landed;
50}
51
52const ADDR = 'sync-alice@example.com';
53const PW = 'app-password-' + '3141';
54
55const s = await open({ name: 'mailsync', signIn: true, connect: true });
56const { page } = s;
57
58await page.waitForFunction(
59 () => !!window.DaimondSync && !!window.DaimondCore && !!window.DaimondMail
60 && !!window.DaimondGateway && DaimondGateway.state().authed,
61 null, { timeout: 12000 },
62).catch(() => {});
63
64try {
65 const GWDIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'gateway');
66 const lic = await makePagePro(page, GWDIR);
67 check('the account holds Pro, so mail and sync are both open to it',
68 lic.pro === true, `webhook ${lic.status}, pro=${lic.pro}`);
69
70 // ── 1. The module offers the parcel at all ────────────────────────
71 const api = await page.evaluate(() => ({
72 exportSync: typeof window.DaimondMail.exportSync === 'function',
73 applySync: typeof window.DaimondMail.applySync === 'function',
74 }));
75 check('mail.js offers exportSync/applySync for the parcel',
76 api.exportSync && api.applySync, JSON.stringify(api));
77 if (!api.exportSync || !api.applySync) throw new Error('no mail sync API to test');
78
79 // ── 2. Add a mailbox the way a person does ────────────────────────
80 // Through the panel's own button and the real dialog, so what is measured
81 // below is what the add path actually writes — including the stamp the merge
82 // decides on, which a hand-seeded record would simply be given.
83 await page.evaluate(() => {
84 window.DaimondPanels.show('mail');
85 window.DaimondMail.onOpen();
86 });
87 await page.waitForTimeout(1200);
88 await page.click('[data-act="mail-add"]', { force: true });
89 await page.waitForSelector('#admin-form .dlg-input', { timeout: 8000 });
90 const fields = await page.$$('#admin-form .dlg-input');
91 check('the add-a-mailbox form asks for the six things a mailbox needs',
92 fields.length === 6, fields.length + ' fields');
93 // Address first: typing it guesses the servers, and a guess must not land on
94 // top of something already typed.
95 await fields[0].fill(ADDR);
96 await page.waitForTimeout(200);
97 await fields[1].fill(PW);
98 await fields[2].fill('imap.example.com');
99 await fields[3].fill('993');
100 await fields[4].fill('smtp.example.com');
101 await fields[5].fill('587');
102 await page.click('#admin-form .dlg-ok', { force: true });
103 await page.waitForTimeout(1200);
104
105 const added = await page.evaluate(async (addr) => {
106 const j = JSON.parse(localStorage.getItem('daimond-mail') || '{}');
107 const a = (j.accounts || []).find(x => x.address === addr);
108 if (!a) return { present: false };
109 return {
110 present: true,
111 touched: a.touched || 0,
112 host: a.host,
113 port: a.port,
114 user: a.user,
115 wrapped: !!a.pass,
116 opens: await window.DaimondIdentity.unwrap(a.pass).then(v => !!v).catch(() => false),
117 };
118 }, ADDR);
119 check('the mailbox was added through the real dialog', added.present === true,
120 JSON.stringify(added).slice(0, 120));
121 check('and carries a stamp for the merge to decide on',
122 added.touched > 0, 'touched=' + added.touched);
123
124 // ── 3. What the export carries, and what it leaves behind ─────────
125 const ex = await page.evaluate((addr) => {
126 const M = window.DaimondMail;
127 // Give the account some folder state, which is exactly what must NOT travel.
128 const j = JSON.parse(localStorage.getItem('daimond-mail') || '{}');
129 const a = (j.accounts || []).find(x => x.address === addr);
130 a.folders = { INBOX: { dir: 'INBOX', uidValidity: 42, lastUid: 9001, firstUid: 12,
131 heldBack: 3, limit: 50, lastSync: Date.now() } };
132 a.folder = 'Archive';
133 a.lastSync = Date.now();
134 localStorage.setItem('daimond-mail', JSON.stringify(j));
135 M.reload();
136 const e1 = JSON.stringify(M.exportSync());
137 const e2 = JSON.stringify(M.exportSync());
138 const row = M.exportSync().accounts.find(x => x.address === addr) || {};
139 return {
140 deterministic: e1 === e2,
141 bytes: e1.length,
142 row: row,
143 keys: Object.keys(row),
144 plaintext: e1.indexOf('app-password-' + '3141') !== -1,
145 hasFolders: e1.indexOf('uidValidity') !== -1 || e1.indexOf('9001') !== -1,
146 hasSel: typeof M.exportSync().sel === 'string',
147 };
148 }, ADDR);
149 check('two exports of one mailbox list are byte-identical', ex.deterministic === true);
150 check('the export carries the server configuration',
151 ex.row.host === 'imap.example.com' && ex.row.port === 993
152 && ex.row.smtpHost === 'smtp.example.com' && ex.row.smtpPort === 587
153 && ex.row.user === ADDR, JSON.stringify(ex.row).slice(0, 140));
154 check('it carries the WRAPPED password, and no readable one',
155 !!ex.row.pass && ex.plaintext === false, 'pass len=' + String(ex.row.pass || '').length);
156 check('the per-folder Maildir state is left behind (it is this device’s)',
157 ex.hasFolders === false && ex.keys.indexOf('folders') === -1, ex.keys.join(','));
158 check('and so is the folder on screen — a fresh device starts in INBOX',
159 ex.keys.indexOf('folder') === -1 && ex.keys.indexOf('lastSync') === -1, ex.keys.join(','));
160 check('which mailbox is selected does travel', ex.hasSel === true);
161 console.log(' note the mail section adds ~' + ex.bytes + ' bytes to the parcel');
162
163 // ── 4. Through the real gateway, onto a second device ─────────────
164 // The parcel goes out sealed, the local mail state is wiped as a fresh device
165 // would have it, and the pull has to put a WORKING mailbox back.
166 await pushLanded(page);
167 // The mailbox server holds it, and holds it as ciphertext: the address is in
168 // there, and must not be readable in there. (The version is not asserted to
169 // have MOVED -- adding an account already triggers the engine's own push, so
170 // by the time this runs there may be nothing left to send.)
171 const landed = await page.evaluate(async (addr) => {
172 const r = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } });
173 const j = await r.json();
174 return { present: !!j.present, version: j.version || 0,
175 leaks: String(j.blob || '').indexOf(addr) !== -1 };
176 }, ADDR);
177 check('the parcel with the mailbox in it is on the server', landed.present && landed.version >= 1,
178 JSON.stringify(landed));
179 check('and the mailbox address is not readable in it',
180 landed.leaks === false);
181
182 const second = await page.evaluate(async (addr) => {
183 localStorage.removeItem('daimond-mail');
184 localStorage.removeItem('daimond-sync-version');
185 window.DaimondMail.reload();
186 await window.DaimondSync.pull();
187 const j = JSON.parse(localStorage.getItem('daimond-mail') || '{}');
188 const a = (j.accounts || []).find(x => x.address === addr);
189 if (!a) return { arrived: false };
190 let opened = '';
191 try { opened = await window.DaimondIdentity.unwrap(a.pass); } catch (e) { opened = ''; }
192 return {
193 arrived: true,
194 address: a.address,
195 host: a.host,
196 port: a.port,
197 user: a.user,
198 smtpHost: a.smtpHost,
199 smtpPort: a.smtpPort,
200 // The secret itself is never reported: only whether it opened, and
201 // whether it is the one that was typed.
202 opens: opened.length > 0,
203 samePass: opened === 'app-password-' + '3141',
204 folder: a.folder,
205 folders: Object.keys(a.folders || {}),
206 lastUid: ((a.folders || {}).INBOX || {}).lastUid || 0,
207 sel: j.sel,
208 panel: [...document.querySelectorAll('#mail-accounts .mail-addr')].map(e => e.textContent),
209 };
210 }, ADDR);
211 check('a device with no mail of its own pulls the mailbox in',
212 second.arrived === true && second.address === ADDR, JSON.stringify(second).slice(0, 120));
213 check('with the server configuration intact',
214 second.host === 'imap.example.com' && second.port === 993
215 && second.user === ADDR && second.smtpHost === 'smtp.example.com' && second.smtpPort === 587,
216 JSON.stringify(second).slice(0, 160));
217 check('and the password still OPENS — the mailbox works, it is not just listed',
218 second.opens === true && second.samePass === true,
219 'opens=' + second.opens + ' matches=' + second.samePass);
220 check('the per-folder state did NOT travel: the folders rebuild here',
221 second.lastUid === 0 && JSON.stringify(second.folders) === JSON.stringify(['INBOX']),
222 'folders=' + JSON.stringify(second.folders) + ' lastUid=' + second.lastUid);
223 check('the arriving mailbox starts in its inbox', second.folder === 'INBOX', second.folder);
224 check('and the panel shows it without a reload',
225 second.panel.indexOf(ADDR) !== -1, JSON.stringify(second.panel));
226
227 // ── 5. A mailbox changed HERE is not overwritten by an older copy ──
228 const diverged = await page.evaluate(async (addr) => {
229 const M = window.DaimondMail;
230 const j = JSON.parse(localStorage.getItem('daimond-mail') || '{}');
231 const a = (j.accounts || []).find(x => x.address === addr);
232 const stamp = Date.now();
233 a.host = 'imap.moved.example.com';
234 a.touched = stamp;
235 localStorage.setItem('daimond-mail', JSON.stringify(j));
236 M.reload();
237 // The other device's copy, as it was before the change.
238 await M.applySync({ v: 1, sel: addr, accounts: [{
239 address: addr, host: 'imap.example.com', port: 993,
240 smtpHost: 'smtp.example.com', smtpPort: 587, user: addr,
241 pass: 'STALE-WRAPPED', touched: stamp - 60000,
242 }] });
243 const older = M.exportSync().accounts.find(x => x.address === addr) || {};
244 // An equal stamp keeps what is here, so an unchanged mailbox is not
245 // rewritten on every pull.
246 await M.applySync({ v: 1, accounts: [{
247 address: addr, host: 'imap.equal.example.com', port: 993,
248 smtpHost: 'smtp.example.com', smtpPort: 587, user: addr,
249 pass: 'EQUAL-WRAPPED', touched: stamp,
250 }] });
251 const equal = M.exportSync().accounts.find(x => x.address === addr) || {};
252 // A fresher one wins.
253 await M.applySync({ v: 1, accounts: [{
254 address: addr, host: 'imap.fresh.example.com', port: 993,
255 smtpHost: 'smtp.example.com', smtpPort: 587, user: addr,
256 pass: 'FRESH-WRAPPED', touched: stamp + 60000,
257 }] });
258 const fresh = M.exportSync().accounts.find(x => x.address === addr) || {};
259 // A mailbox only the other device has arrives whole, and this one's survives.
260 await M.applySync({ v: 1, accounts: [{
261 address: 'other@example.com', host: 'imap.other.example.com', port: 143,
262 smtpHost: 'smtp.other.example.com', smtpPort: 465, user: 'other@example.com',
263 pass: 'OTHER-WRAPPED', touched: Date.now(), security: 'starttls',
264 }] });
265 const both = M.exportSync().accounts.map(x => x.address).sort();
266 const other = M.exportSync().accounts.find(x => x.address === 'other@example.com') || {};
267 return { older: older, equal: equal, fresh: fresh, both: both, other: other };
268 }, ADDR);
269 check('an older copy from another device does not clobber a mailbox changed here',
270 diverged.older.host === 'imap.moved.example.com' && diverged.older.pass !== 'STALE-WRAPPED',
271 diverged.older.host);
272 check('an equally-stamped copy keeps what is here (the comparison is strict)',
273 diverged.equal.host === 'imap.moved.example.com', diverged.equal.host);
274 check('a fresher copy wins', diverged.fresh.host === 'imap.fresh.example.com'
275 && diverged.fresh.pass === 'FRESH-WRAPPED', diverged.fresh.host);
276 check('a mailbox only the other device has arrives, and this one survives',
277 JSON.stringify(diverged.both) === JSON.stringify(['other@example.com', ADDR].sort()),
278 JSON.stringify(diverged.both));
279 check('how the connection is dialled travels with it',
280 diverged.other.security === 'starttls' && diverged.other.port === 143,
281 JSON.stringify(diverged.other).slice(0, 100));
282
283 // ── 6. A deletion travels as a deletion ───────────────────────────
284 // Through the panel's own × and its confirmation, so every UI path that
285 // removes a mailbox is the path under test.
286 await page.evaluate(() => { window.DaimondMail.onOpen(); });
287 await page.waitForTimeout(400);
288 const rows = await page.$$('#mail-accounts .mail-acct');
289 check('both mailboxes are on the panel to be removed from it', rows.length === 2,
290 rows.length + ' rows');
291 // The closer cross is gone (phase G part two): Remove is at the foot of the
292 // gear's dialog, exactly as Delete is at the foot of a tile's. Two clicks now,
293 // and the second dialog is the CONFIRM -- so the button pressed for it has to be
294 // the topmost card's, not the first visible one, since the settings dialog also
295 // carries `.dlg-card`.
296 const target = await page.evaluateHandle((addr) => {
297 const row = [...document.querySelectorAll('#mail-accounts .mail-acct')]
298 .find(r => (r.querySelector('.mail-addr') || {}).textContent === addr);
299 return row ? row.querySelector('.mail-gear') : null;
300 }, ADDR);
301 await target.asElement().click({ force: true });
302 await page.waitForSelector('.tile-dlg-delete', { timeout: 5000 });
303 await page.evaluate(() => document.querySelector('.tile-dlg-delete').click());
304 await page.waitForSelector('.modal.dlg .dlg-ok', { timeout: 5000 });
305 await page.evaluate(() => {
306 const card = [...document.querySelectorAll('.dlg-card')]
307 .filter(c => c.getClientRects().length).pop();
308 card.querySelector('.dlg-ok').click();
309 });
310 await page.waitForTimeout(600);
311
312 const buried = await page.evaluate(async (addr) => {
313 const M = window.DaimondMail;
314 const e = M.exportSync();
315 const gone = !e.accounts.some(x => x.address === addr);
316 const tombed = !!(e.tombs && e.tombs[addr]);
317 // The other device still holds it, and hands it back: it must not come.
318 await M.applySync({ v: 1, accounts: [{
319 address: addr, host: 'imap.example.com', port: 993,
320 smtpHost: 'smtp.example.com', smtpPort: 587, user: addr,
321 pass: 'RESURRECTED', touched: (e.tombs[addr] || Date.now()) - 60000,
322 }] });
323 const back = M.exportSync().accounts.some(x => x.address === addr);
324 // A deletion made on the OTHER device reaches this one: its tombstone
325 // arrives without the account, and the account here goes.
326 const other = 'other@example.com';
327 await M.applySync({ v: 1, accounts: [], tombs: { [other]: Date.now() } });
328 const otherGone = !M.exportSync().accounts.some(x => x.address === other);
329 // And a mailbox re-added AFTER the deletion is not buried by the old tomb.
330 const readd = Date.now() + 1000;
331 await M.applySync({ v: 1, accounts: [{
332 address: addr, host: 'imap.readded.example.com', port: 993,
333 smtpHost: 'smtp.example.com', smtpPort: 587, user: addr,
334 pass: 'READDED', touched: readd,
335 }] });
336 const row = M.exportSync().accounts.find(x => x.address === addr) || null;
337 return { gone, tombed, back, otherGone, readd: row ? row.host : '' };
338 }, ADDR);
339 check('removing a mailbox in the panel takes it off this device',
340 buried.gone === true);
341 check('and leaves a tombstone in the parcel, so the other device removes it too',
342 buried.tombed === true);
343 check('a deleted mailbox handed back by the other device does not come back',
344 buried.back === false);
345 check('a mailbox deleted on the other device is deleted here',
346 buried.otherGone === true);
347 check('a mailbox re-added after the deletion survives the next merge',
348 buried.readd === 'imap.readded.example.com', buried.readd || '(gone)');
349
350 // ── 7. A device that predates all this ────────────────────────────
351 const old = await page.evaluate(async () => {
352 const M = window.DaimondMail;
353 let threw = '';
354 const before = M.exportSync().accounts.length;
355 try {
356 await M.applySync(undefined);
357 await M.applySync(null);
358 await M.applySync({});
359 await M.applySync({ v: 1 });
360 } catch (e) { threw = String((e && e.message) || e); }
361 return { threw, before, after: M.exportSync().accounts.length };
362 });
363 check('a parcel with no mail section applies as a no-op',
364 old.threw === '' && old.after === old.before,
365 old.threw || (old.before + ' → ' + old.after));
366
367 // The whole point of carrying the mailboxes is that the parcel from a device
368 // that HAS them is still the parcel the core collects.
369 const inParcel = await page.evaluate(async () => {
370 const p = await window.DaimondCore.collectSync();
371 return { has: Object.prototype.hasOwnProperty.call(p, 'mail'),
372 accounts: (p.mail && p.mail.accounts || []).length };
373 });
374 check('the core parcel carries the mail section', inParcel.has === true,
375 JSON.stringify(inParcel));
376
377 const errs = s.errs.filter(e => !/favicon|ERR_|Failed to load resource|401|402|409|413|426|502|Unauthorized/.test(e));
378 check('no unexpected console errors', errs.length === 0, errs.slice(0, 3).join(' | '));
379} catch (e) {
380 check('verify_mailsync ran without throwing', false, String((e && e.message) || e));
381} finally {
382 await s.close?.().catch?.(() => {});
383}
384
385console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`));
386process.exit(bad.length ? 1 : 0);