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). |
| 24 | import { open, scratch } from './harness.mjs'; |
| 25 | import { makePagePro } from './pro.mjs'; |
| 26 | import path from 'node:path'; |
| 27 | import { fileURLToPath } from 'node:url'; |
| 28 | |
| 29 | const ok = [], bad = []; |
| 30 | const 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. |
| 38 | async 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 | |
| 52 | const ADDR = 'sync-alice@example.com'; |
| 53 | const PW = 'app-password-' + '3141'; |
| 54 | |
| 55 | const s = await open({ name: 'mailsync', signIn: true, connect: true }); |
| 56 | const { page } = s; |
| 57 | |
| 58 | await page.waitForFunction( |
| 59 | () => !!window.DaimondSync && !!window.DaimondCore && !!window.DaimondMail |
| 60 | && !!window.DaimondGateway && DaimondGateway.state().authed, |
| 61 | null, { timeout: 12000 }, |
| 62 | ).catch(() => {}); |
| 63 | |
| 64 | try { |
| 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 | |
| 385 | console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`)); |
| 386 | process.exit(bad.length ? 1 : 0); |