gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit836f644f836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre836f644f/tests/migration.mjs

14.6 KB

  1. // tests/migration.mjs — THE MIGRATION GATE (mission 009, ticket ident #10 / old #39):
  2. // an OLD-FORMAT store (tests/oldstore-009.hl: `*id` tables, numeric public ids, sessions
  3. // holding `user: {id: <number>}`) → tools/migrate-009.hl on a COPY, twice (idempotent) →
  4. // the new code on the migrated store:
  5. // * a migrated session is still signed in (HTTP and a real Chrome);
  6. // * an app's STORED per-app ids come out of the exchange again with its UNCHANGED key +
  7. // secret (login button in Chrome through the test app → "welcome back"; selector);
  8. // * a code issued before the migration (grants) still exchanges; the per-app id of a
  9. // deleted identity stays; every appIdentity byte-identical.
  10. // Own servers: ident :8395, test app :8396 (the fixture app's origin); storage
  11. // .scratch/migration-gate (wiped at start); Chrome on 8710-8719. Screenshot .scratch/migration-*.png
  12. import { spawn, execFileSync } from 'node:child_process';
  13. import { readFileSync, writeFileSync, rmSync, mkdirSync, cpSync } from 'node:fs';
  14. import { createHash } from 'node:crypto';
  15. import { dirname, join } from 'node:path';
  16. import { fileURLToPath } from 'node:url';
  17. import { launchBrowser, sleep } from './cdp.mjs';
  18. const APP = join(dirname(fileURLToPath(import.meta.url)), '..');
  19. const G = join(APP, '.scratch/migration-gate');
  20. const BIN = join(APP, 'bin/hybriel');
  21. const ID = 'http://127.0.0.1:8395', TA = 'http://127.0.0.1:8396';
  22. const J = JSON.stringify;
  23. const x = (c, n) => c.repeat(n);
  24. const K1 = 'pk_' + x('1', 32), S1 = 'sk_' + x('2', 48), K2 = 'pk_' + x('3', 32), S2 = 'sk_' + x('4', 48);
  25. const AI = { def: x('a', 32), work: x('b', 32), gone: x('c', 32), bob: x('d', 32) };
  26. let passed = 0, failed = 0;
  27. const check = (name, ok, detail = '') => {
  28. if (ok) { passed++; console.log(' ok ' + name); }
  29. else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
  30. };
  31. const run = (script, env) => execFileSync(BIN, [script], { cwd: APP, env: { ...process.env, ...env }, encoding: 'utf8' });
  32. rmSync(G, { recursive: true, force: true });
  33. mkdirSync(join(G, 'old'), { recursive: true });
  34. mkdirSync(join(G, 'sess'), { recursive: true });
  35. // ---- 1. the old-format store and its sessions -------------------------------------------
  36. console.log('# old store');
  37. const made = run(join(APP, 'tests/oldstore-009.hl'), { OLD_PREFIX: join(G, 'old/ident') });
  38. check('fixture: old store written', /accounts 2 identities 3 apps 2 connections 4 otp 1 sends 1 requests 1 grants 1/.test(made), made.trim());
  39. // hl:webex session files: the file name is sha256(sid), the content JSON (webex sessions.hl)
  40. const SID = { alice: x('1a', 16), bob: x('2b', 16), anon: x('3c', 16) };
  41. const sess = (sid, user) => writeFileSync(join(G, 'sess', createHash('sha256').update(sid).digest('hex')),
  42. J({ created: Date.now() - 5000, data: {}, id: sid, seen: Date.now() - 1000, user }), { mode: 0o600 });
  43. sess(SID.alice, { id: 1 }); sess(SID.bob, { id: 2 }); sess(SID.anon, null);
  44. // ---- 2. migrate a COPY, twice -------------------------------------------------------------
  45. console.log('# migrate');
  46. cpSync(join(G, 'old'), join(G, 'oldcopy'), { recursive: true }); // opening a table rewrites it (hybriel #40)
  47. const menv = { IDENT_OLD_STORAGE: join(G, 'oldcopy/ident'), IDENT_STORAGE: join(G, 'new'), IDENT_SESSIONS_MIGRATE: join(G, 'sess') };
  48. const r1 = run(join(APP, 'tools/migrate-009.hl'), menv);
  49. writeFileSync(join(G, 'migrate-run1.txt'), r1);
  50. check('run 1: accounts 2, identities 3, apps 2, connections 4 (one of a deleted identity), otp/sends/requests/grants 1 each',
  51. /accounts 2 new/.test(r1) && /identities 3 new/.test(r1) && /apps 2 new/.test(r1) && /connections 4 new/.test(r1) && /otp 1 new/.test(r1) && /sends 1 new/.test(r1) && /requests 1 new/.test(r1) && /grants 1 new/.test(r1), r1);
  52. check('run 1: sessions 2 migrated, 1 anonymous left alone; 0 failed', /sessions 2 new, 0 already there, 1 anonymous, 0 left alone/.test(r1) && /migrate: 0 failed/.test(r1), r1);
  53. const r2 = run(join(APP, 'tools/migrate-009.hl'), menv);
  54. writeFileSync(join(G, 'migrate-run2.txt'), r2);
  55. check('run 2 (idempotent): nothing new anywhere, 0 failed', !/ [1-9]\d* new/.test(r2) && /sessions 0 new, 2 already there/.test(r2) && /migrate: 0 failed/.test(r2), r2);
  56. const sAlice = JSON.parse(readFileSync(join(G, 'sess', createHash('sha256').update(SID.alice).digest('hex')), 'utf8'));
  57. check('alice\'s session now holds her account\'s UUID', /^[0-9a-z]{12}$/.test(sAlice.user.id) && sAlice.id === SID.alice, J(sAlice.user));
  58. // the migrated store, read on a COPY
  59. cpSync(join(G, 'new'), join(G, 'newcopy'), { recursive: true });
  60. const st = JSON.parse(run(join(APP, 'tools/dump-store.hl'), { IDENT_STORAGE: join(G, 'newcopy') }));
  61. check('every per-app id byte-identical (4 connections)', J(st.connections.map(c => c.appIdentity).sort()) === J(Object.values(AI).sort()), J(st.connections.map(c => c.appIdentity)));
  62. check('API keys and secret hashes byte-identical', J(st.apps.map(a => [a.apiKey, a.secretHash]).sort()) === J([[K1, createHash('sha256').update(S1).digest('hex')], [K2, createHash('sha256').update(S2).digest('hex')]].sort()));
  63. check('all keys are 12-char UUIDs, references rewritten', Object.values(st).flat().every(r => /^[0-9a-z]{12}$/.test(r.id))
  64. && st.identities.every(i => st.accounts.some(a => a.id === i.account)) && st.apps.every(a => st.accounts.some(c => c.id === a.owner)));
  65. const goneConn = st.connections.find(c => c.appIdentity === AI.gone);
  66. check('the deleted identity\'s connection is kept as gone:4', goneConn && goneConn.identity === 'gone:4', J(goneConn));
  67. // ---- 3. the new code on the migrated store -------------------------------------------------
  68. const procs = [];
  69. function start(cwd, env, log) {
  70. const p = spawn(BIN, ['project.hl'], { cwd, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
  71. let out = ''; p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { out += d; });
  72. p.on('exit', () => writeFileSync(join(G, log), out));
  73. procs.push(p);
  74. }
  75. start(APP, { IDENT_PORT: '8395', IDENT_STORAGE: join(G, 'new'), IDENT_SESSIONS: join(G, 'sess') + '/', IDENT_MAIL_SINK: join(G, 'mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000' }, 'ident.log');
  76. for (let i = 0; i < 80; i++) { try { await fetch(ID + '/', { redirect: 'manual' }); break; } catch {} await sleep(250); }
  77. // ident#23: the identities got their short ids at boot; each app trades its stored per-app ids for them in one step
  78. const migrateIds = async (key, secret, finish) => { const r = await fetch(ID + '/api/migrate-ids', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J(finish ? { key, secret, finish } : { key, secret }) }); return { status: r.status, j: await r.json() }; };
  79. const m1 = await migrateIds(K1, S1), m2 = await migrateIds(K2, S2);
  80. const SHORT = /^[2-9a-hj-km-np-z]{5}$/;
  81. check('migrate-ids (app 1): Default and Work map to short ids; the deleted identity is left out',
  82. m1.status === 200 && J(Object.keys(m1.j.ids).sort()) === J([AI.def, AI.work].sort()) && Object.values(m1.j.ids).every(v => SHORT.test(v)) && m1.j.ids[AI.def] !== m1.j.ids[AI.work], J(m1));
  83. check('migrate-ids (app 2): Bob maps to a short id', m2.status === 200 && J(Object.keys(m2.j.ids)) === J([AI.bob]) && SHORT.test(m2.j.ids[AI.bob]), J(m2));
  84. const M = { ...m1.j.ids, ...m2.j.ids };
  85. const wrong = await migrateIds(K1, S2);
  86. check('migrate-ids with the wrong secret → 401', wrong.status === 401, J(wrong));
  87. writeFileSync(join(G, 'testapp.json'), J({ key: K1, secret: S1, users: { [M[AI.def]]: { n: 1 }, [M[AI.work]]: { n: 2 } }, count: 2, sessions: {} }));
  88. start(join(APP, 'testapp'), { TESTAPP_PORT: '8396', TESTAPP_URL: TA, IDENT_URL: ID, TESTAPP_STORE: join(G, 'testapp.json') }, 'testapp.log');
  89. for (let i = 0; i < 80; i++) { try { await fetch(TA + '/', { redirect: 'manual' }); break; } catch {} await sleep(250); }
  90. let browser = null;
  91. try {
  92. console.log('# sessions');
  93. const home = async (sid) => (await fetch(ID + '/', { headers: { cookie: 'identsid=' + sid } })).text();
  94. let h = await home(SID.alice);
  95. check('alice\'s OLD cookie: still signed in, her identities (Default first = default, Work; the deleted one absent)',
  96. h.includes('[email protected]') && /Default[\s\S]*default[\s\S]*Work/.test(h) && !h.includes('>Old<'), h.slice(0, 200));
  97. h = await home(SID.bob);
  98. check('bob\'s OLD cookie: still signed in as bob', h.includes('[email protected]') && !h.includes('[email protected]'));
  99. h = await home(SID.anon);
  100. check('the anonymous session: signed out (sign-in form)', h.includes('id="emailform"') && !h.includes('id="meemail"'));
  101. const aliceIds = [...new Set([...(await home(SID.alice)).matchAll(/value="([0-9a-z]{12})"/g)].map(m => m[1]))];
  102. check('alice\'s identity ids are UUIDs (2)', aliceIds.length === 2, J(aliceIds));
  103. console.log('# exchange');
  104. const exchange = async (body) => { const r = await fetch(ID + '/api/exchange', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J(body) }); return { status: r.status, j: await r.json() }; };
  105. let e = await exchange({ key: K1, secret: S1, code: x('e', 48) });
  106. check('a code issued BEFORE the migration still exchanges → the Work identity\'s stored id', e.status === 200 && e.j.identity === M[AI.work], J(e));
  107. e = await exchange({ key: K1, secret: S1, code: x('e', 48) });
  108. check('…once', e.status === 400, J(e));
  109. const emit = async (event, payload, sid) => (await (await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie: 'identsid=' + sid }, body: J({ t: 'emit', i: 1, event, payload }) })).json()).value;
  110. const bobIds = [...new Set([...(await home(SID.bob)).matchAll(/value="([0-9a-z]{12})"/g)].map(m => m[1]))];
  111. const l = await fetch(ID + '/login?key=' + K2 + '&return=' + encodeURIComponent('http://127.0.0.1:8397/cb'), { redirect: 'manual' });
  112. const v = await emit('chooseIdentity', [l.headers.get('location').split('/').pop(), bobIds[0]], SID.bob);
  113. e = await exchange({ key: K2, secret: S2, code: new URL(v.url).searchParams.get('ident_code') });
  114. check('bob (old cookie) → Bob app (unchanged key + secret) → his stored id', e.status === 200 && e.j.identity === M[AI.bob], J(e));
  115. const sel = await fetch(ID + '/api/selector/identities?key=' + K1, { headers: { origin: TA, cookie: 'identsid=' + SID.alice } });
  116. const sj = await sel.json();
  117. check('selector: signed in, Default and Work', sel.status === 200 && sj.signedIn && J(sj.identities.map(i => i.name)) === J(['Default', 'Work']), J(sj));
  118. const ch = await (await fetch(ID + '/api/selector/choose?key=' + K1, { method: 'POST', headers: { origin: TA, cookie: 'identsid=' + SID.alice, 'content-type': 'application/json' }, body: J({ identity: sj.identities[1].id }) })).json();
  119. e = await exchange({ key: K1, secret: S1, code: ch.code });
  120. check('selector choice Work → exchange → the stored Work id', e.status === 200 && e.j.identity === M[AI.work], J(e));
  121. console.log('# old ids until the app has migrated');
  122. const notify = async (key, secret, identity) => { const r = await fetch(ID + '/api/notify', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ key, secret, identity, name: 'New comment', text: 'hello' }) }); return r.status; };
  123. check('notify: the OLD per-app id still works before the app migrated', (await notify(K1, S1, AI.work)) === 200);
  124. check('notify: the short id works too', (await notify(K1, S1, M[AI.work])) === 200);
  125. check('notify: the old id of ANOTHER app\'s user → 404', (await notify(K1, S1, AI.bob)) === 404);
  126. const fin = await migrateIds(K1, S1, true);
  127. check('migrate-ids finish: same map, marked finished', fin.status === 200 && fin.j.finished === true && J(fin.j.ids) === J(m1.j.ids), J(fin));
  128. check('after finish: the old id is gone (404), the short id still works', (await notify(K1, S1, AI.work)) === 404 && (await notify(K1, S1, M[AI.work])) === 200);
  129. check('after finish: nothing left to map', J((await migrateIds(K1, S1)).j.ids) === '{}');
  130. check('app 2 was not finished: its old id still works', (await notify(K2, S2, AI.bob)) === 200);
  131. console.log('# browser');
  132. browser = await launchBrowser({ debugPortRange: [8710, 8719] });
  133. const p = await browser.newPage();
  134. await p.send('Network.enable');
  135. await p.send('Network.setCookie', { name: 'identsid', value: SID.alice, url: ID, httpOnly: true, sameSite: 'Lax' });
  136. await p.goto(ID + '/');
  137. await p.waitFor('!!document.querySelector("#identities") && !!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'ident signed in' });
  138. check('Chrome with alice\'s old cookie: signed in, Default (default badge) and Work',
  139. (await p.evaluate('document.querySelector("#meemail").textContent')) === '[email protected]'
  140. && J(await p.evaluate('[...document.querySelectorAll("#identities li")].map(li => li.querySelector("identity-label, identityLabel").textContent + (li.querySelector("default-badge, defaultBadge") ? "*" : ""))')) === J(['Default*', 'Work']));
  141. const { data } = await p.send('Page.captureScreenshot', { format: 'png' });
  142. writeFileSync(join(APP, '.scratch/migration-signedin-1280.png'), Buffer.from(data, 'base64'));
  143. const viaButton = async (nth) => {
  144. await p.goto(TA + '/');
  145. await p.click('#login');
  146. await p.waitFor('location.pathname.startsWith("/signin/") && !!document.querySelector("#chooselist") && !!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'choice' });
  147. await p.click(`#chooselist li:nth-child(${nth}) .choose`);
  148. await p.waitFor(`location.href.startsWith(${J(TA + '/callback')}) && !!document.querySelector("#result")`, { label: 'back at the app' });
  149. return { identity: await p.evaluate('document.querySelector("#identity").textContent.trim()'), state: await p.evaluate('document.querySelector("#userstate").textContent') };
  150. };
  151. let b = await viaButton(1);
  152. check('Chrome: test app → login button → Default → the SAME stored id, "welcome back"', b.identity === M[AI.def] && b.state === 'welcome back', J(b));
  153. b = await viaButton(2);
  154. check('Chrome: test app → login button → Work → the SAME stored id, "welcome back"', b.identity === M[AI.work] && b.state === 'welcome back', J(b));
  155. const shot = await p.send('Page.captureScreenshot', { format: 'png' });
  156. writeFileSync(join(APP, '.scratch/migration-testapp-1280.png'), Buffer.from(shot.data, 'base64'));
  157. const ta = JSON.parse(readFileSync(join(G, 'testapp.json'), 'utf8'));
  158. check('the test app made no new users (count still 2)', ta.count === 2, J(ta.count));
  159. check('Chrome: no console errors', p.problems().length === 0, J(p.problems()));
  160. } catch (err) {
  161. failed++; console.log(' FAIL (aborted) ' + err.stack);
  162. } finally {
  163. if (browser) await browser.close();
  164. for (const p of procs) { try { p.kill(); } catch {} }
  165. await sleep(300);
  166. }
  167. console.log(`\n${passed} passed, ${failed} failed`);
  168. process.exit(failed ? 1 : 0);

Branches

Latest commits

  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre