ident
All repositories: gitoria
14.6 KB
// tests/migration.mjs — THE MIGRATION GATE (mission 009, ticket ident #10 / old #39):// an OLD-FORMAT store (tests/oldstore-009.hl: `*id` tables, numeric public ids, sessions// holding `user: {id: <number>}`) → tools/migrate-009.hl on a COPY, twice (idempotent) →// the new code on the migrated store:// * a migrated session is still signed in (HTTP and a real Chrome);// * an app's STORED per-app ids come out of the exchange again with its UNCHANGED key +// secret (login button in Chrome through the test app → "welcome back"; selector);// * a code issued before the migration (grants) still exchanges; the per-app id of a// deleted identity stays; every appIdentity byte-identical.// Own servers: ident :8395, test app :8396 (the fixture app's origin); storage// .scratch/migration-gate (wiped at start); Chrome on 8710-8719. Screenshot .scratch/migration-*.pngimport { spawn, execFileSync } from 'node:child_process';import { readFileSync, writeFileSync, rmSync, mkdirSync, cpSync } from 'node:fs';import { createHash } from 'node:crypto';import { dirname, join } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser, sleep } from './cdp.mjs';const APP = join(dirname(fileURLToPath(import.meta.url)), '..');const G = join(APP, '.scratch/migration-gate');const BIN = join(APP, 'bin/hybriel');const ID = 'http://127.0.0.1:8395', TA = 'http://127.0.0.1:8396';const J = JSON.stringify;const x = (c, n) => c.repeat(n);const K1 = 'pk_' + x('1', 32), S1 = 'sk_' + x('2', 48), K2 = 'pk_' + x('3', 32), S2 = 'sk_' + x('4', 48);const AI = { def: x('a', 32), work: x('b', 32), gone: x('c', 32), bob: x('d', 32) };let passed = 0, failed = 0;const check = (name, ok, detail = '') => {if (ok) { passed++; console.log(' ok ' + name); }else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }};const run = (script, env) => execFileSync(BIN, [script], { cwd: APP, env: { ...process.env, ...env }, encoding: 'utf8' });rmSync(G, { recursive: true, force: true });mkdirSync(join(G, 'old'), { recursive: true });mkdirSync(join(G, 'sess'), { recursive: true });// ---- 1. the old-format store and its sessions -------------------------------------------console.log('# old store');const made = run(join(APP, 'tests/oldstore-009.hl'), { OLD_PREFIX: join(G, 'old/ident') });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());// hl:webex session files: the file name is sha256(sid), the content JSON (webex sessions.hl)const SID = { alice: x('1a', 16), bob: x('2b', 16), anon: x('3c', 16) };const sess = (sid, user) => writeFileSync(join(G, 'sess', createHash('sha256').update(sid).digest('hex')),J({ created: Date.now() - 5000, data: {}, id: sid, seen: Date.now() - 1000, user }), { mode: 0o600 });sess(SID.alice, { id: 1 }); sess(SID.bob, { id: 2 }); sess(SID.anon, null);// ---- 2. migrate a COPY, twice -------------------------------------------------------------console.log('# migrate');cpSync(join(G, 'old'), join(G, 'oldcopy'), { recursive: true }); // opening a table rewrites it (hybriel #40)const menv = { IDENT_OLD_STORAGE: join(G, 'oldcopy/ident'), IDENT_STORAGE: join(G, 'new'), IDENT_SESSIONS_MIGRATE: join(G, 'sess') };const r1 = run(join(APP, 'tools/migrate-009.hl'), menv);writeFileSync(join(G, 'migrate-run1.txt'), r1);check('run 1: accounts 2, identities 3, apps 2, connections 4 (one of a deleted identity), otp/sends/requests/grants 1 each',/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);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);const r2 = run(join(APP, 'tools/migrate-009.hl'), menv);writeFileSync(join(G, 'migrate-run2.txt'), r2);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);const sAlice = JSON.parse(readFileSync(join(G, 'sess', createHash('sha256').update(SID.alice).digest('hex')), 'utf8'));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));// the migrated store, read on a COPYcpSync(join(G, 'new'), join(G, 'newcopy'), { recursive: true });const st = JSON.parse(run(join(APP, 'tools/dump-store.hl'), { IDENT_STORAGE: join(G, 'newcopy') }));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)));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()));check('all keys are 12-char UUIDs, references rewritten', Object.values(st).flat().every(r => /^[0-9a-z]{12}$/.test(r.id))&& st.identities.every(i => st.accounts.some(a => a.id === i.account)) && st.apps.every(a => st.accounts.some(c => c.id === a.owner)));const goneConn = st.connections.find(c => c.appIdentity === AI.gone);check('the deleted identity\'s connection is kept as gone:4', goneConn && goneConn.identity === 'gone:4', J(goneConn));// ---- 3. the new code on the migrated store -------------------------------------------------const procs = [];function start(cwd, env, log) {const p = spawn(BIN, ['project.hl'], { cwd, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });let out = ''; p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { out += d; });p.on('exit', () => writeFileSync(join(G, log), out));procs.push(p);}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');for (let i = 0; i < 80; i++) { try { await fetch(ID + '/', { redirect: 'manual' }); break; } catch {} await sleep(250); }// ident#23: the identities got their short ids at boot; each app trades its stored per-app ids for them in one stepconst 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() }; };const m1 = await migrateIds(K1, S1), m2 = await migrateIds(K2, S2);const SHORT = /^[2-9a-hj-km-np-z]{5}$/;check('migrate-ids (app 1): Default and Work map to short ids; the deleted identity is left out',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));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));const M = { ...m1.j.ids, ...m2.j.ids };const wrong = await migrateIds(K1, S2);check('migrate-ids with the wrong secret → 401', wrong.status === 401, J(wrong));writeFileSync(join(G, 'testapp.json'), J({ key: K1, secret: S1, users: { [M[AI.def]]: { n: 1 }, [M[AI.work]]: { n: 2 } }, count: 2, sessions: {} }));start(join(APP, 'testapp'), { TESTAPP_PORT: '8396', TESTAPP_URL: TA, IDENT_URL: ID, TESTAPP_STORE: join(G, 'testapp.json') }, 'testapp.log');for (let i = 0; i < 80; i++) { try { await fetch(TA + '/', { redirect: 'manual' }); break; } catch {} await sleep(250); }let browser = null;try {console.log('# sessions');const home = async (sid) => (await fetch(ID + '/', { headers: { cookie: 'identsid=' + sid } })).text();let h = await home(SID.alice);check('alice\'s OLD cookie: still signed in, her identities (Default first = default, Work; the deleted one absent)',h.includes('[email protected]') && /Default[\s\S]*default[\s\S]*Work/.test(h) && !h.includes('>Old<'), h.slice(0, 200));h = await home(SID.bob);check('bob\'s OLD cookie: still signed in as bob', h.includes('[email protected]') && !h.includes('[email protected]'));h = await home(SID.anon);check('the anonymous session: signed out (sign-in form)', h.includes('id="emailform"') && !h.includes('id="meemail"'));const aliceIds = [...new Set([...(await home(SID.alice)).matchAll(/value="([0-9a-z]{12})"/g)].map(m => m[1]))];check('alice\'s identity ids are UUIDs (2)', aliceIds.length === 2, J(aliceIds));console.log('# exchange');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() }; };let e = await exchange({ key: K1, secret: S1, code: x('e', 48) });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));e = await exchange({ key: K1, secret: S1, code: x('e', 48) });check('…once', e.status === 400, J(e));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;const bobIds = [...new Set([...(await home(SID.bob)).matchAll(/value="([0-9a-z]{12})"/g)].map(m => m[1]))];const l = await fetch(ID + '/login?key=' + K2 + '&return=' + encodeURIComponent('http://127.0.0.1:8397/cb'), { redirect: 'manual' });const v = await emit('chooseIdentity', [l.headers.get('location').split('/').pop(), bobIds[0]], SID.bob);e = await exchange({ key: K2, secret: S2, code: new URL(v.url).searchParams.get('ident_code') });check('bob (old cookie) → Bob app (unchanged key + secret) → his stored id', e.status === 200 && e.j.identity === M[AI.bob], J(e));const sel = await fetch(ID + '/api/selector/identities?key=' + K1, { headers: { origin: TA, cookie: 'identsid=' + SID.alice } });const sj = await sel.json();check('selector: signed in, Default and Work', sel.status === 200 && sj.signedIn && J(sj.identities.map(i => i.name)) === J(['Default', 'Work']), J(sj));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();e = await exchange({ key: K1, secret: S1, code: ch.code });check('selector choice Work → exchange → the stored Work id', e.status === 200 && e.j.identity === M[AI.work], J(e));console.log('# old ids until the app has migrated');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; };check('notify: the OLD per-app id still works before the app migrated', (await notify(K1, S1, AI.work)) === 200);check('notify: the short id works too', (await notify(K1, S1, M[AI.work])) === 200);check('notify: the old id of ANOTHER app\'s user → 404', (await notify(K1, S1, AI.bob)) === 404);const fin = await migrateIds(K1, S1, true);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));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);check('after finish: nothing left to map', J((await migrateIds(K1, S1)).j.ids) === '{}');check('app 2 was not finished: its old id still works', (await notify(K2, S2, AI.bob)) === 200);console.log('# browser');browser = await launchBrowser({ debugPortRange: [8710, 8719] });const p = await browser.newPage();await p.send('Network.enable');await p.send('Network.setCookie', { name: 'identsid', value: SID.alice, url: ID, httpOnly: true, sameSite: 'Lax' });await p.goto(ID + '/');await p.waitFor('!!document.querySelector("#identities") && !!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'ident signed in' });check('Chrome with alice\'s old cookie: signed in, Default (default badge) and Work',(await p.evaluate('document.querySelector("#meemail").textContent')) === '[email protected]'&& J(await p.evaluate('[...document.querySelectorAll("#identities li")].map(li => li.querySelector("identity-label, identityLabel").textContent + (li.querySelector("default-badge, defaultBadge") ? "*" : ""))')) === J(['Default*', 'Work']));const { data } = await p.send('Page.captureScreenshot', { format: 'png' });writeFileSync(join(APP, '.scratch/migration-signedin-1280.png'), Buffer.from(data, 'base64'));const viaButton = async (nth) => {await p.goto(TA + '/');await p.click('#login');await p.waitFor('location.pathname.startsWith("/signin/") && !!document.querySelector("#chooselist") && !!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'choice' });await p.click(`#chooselist li:nth-child(${nth}) .choose`);await p.waitFor(`location.href.startsWith(${J(TA + '/callback')}) && !!document.querySelector("#result")`, { label: 'back at the app' });return { identity: await p.evaluate('document.querySelector("#identity").textContent.trim()'), state: await p.evaluate('document.querySelector("#userstate").textContent') };};let b = await viaButton(1);check('Chrome: test app → login button → Default → the SAME stored id, "welcome back"', b.identity === M[AI.def] && b.state === 'welcome back', J(b));b = await viaButton(2);check('Chrome: test app → login button → Work → the SAME stored id, "welcome back"', b.identity === M[AI.work] && b.state === 'welcome back', J(b));const shot = await p.send('Page.captureScreenshot', { format: 'png' });writeFileSync(join(APP, '.scratch/migration-testapp-1280.png'), Buffer.from(shot.data, 'base64'));const ta = JSON.parse(readFileSync(join(G, 'testapp.json'), 'utf8'));check('the test app made no new users (count still 2)', ta.count === 2, J(ta.count));check('Chrome: no console errors', p.problems().length === 0, J(p.problems()));} catch (err) {failed++; console.log(' FAIL (aborted) ' + err.stack);} finally {if (browser) await browser.close();for (const p of procs) { try { p.kill(); } catch {} }await sleep(300);}console.log(`\n${passed} passed, ${failed} failed`);process.exit(failed ? 1 : 0);
Branches
- mainmain branch
Latest commits
- 81b15b7bState of 2026-09-27, before the move to gitoriamre