gitoria
All repositories: gitoria
18.7 KB
// tests/short-id-switch.mjs — THE SHORT-ID SWITCH GATE of gitoria.worldapi.org (mission 039, ident#23), all over HTTP, no browser:// 1. the OLD ident (the pre-ident#23 build, a copy of its code: OLD_IDENT_DIR, default// ident.worldapi.org/.scratch/pre-023-ident) + this app on a fresh store: alice (Default AND a second identity// "Work") and bob log in and make data; alice's "Work" identity is deleted in ident afterwards;// 2. both stop; the ident store and the app store are COPIED (the originals stay as they were);// 3. the NEW ident (ident#23's dev build, a copy of ident.worldapi.org's code: NEW_IDENT_DIR) on the ident copy;// 4. CONTROL: the app on an UNMIGRATED copy → alice comes back as a NEW user (why the migration is needed);// 5. tools/migrate-short-ids.hl on the other copy, TWICE: counts; the second run changes nothing;// 6. the app on the migrated copy with the new ident: alice's OLD app session still works; a fresh login through the// new ident finds the SAME user with the SAME data; bob too; nobody sees the other's data.// Ports 8724, 8725 (ident, app). Work dir .scratch/m039-switch (wiped at start). Every process started is stopped by PID.// node tests/short-id-switch.mjsimport { spawn, execFileSync } from 'node:child_process';import { cpSync, mkdirSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { copyIdent } from './identkit.mjs';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const BIN = join(APP, 'bin/hybriel');const PROJECTS = resolve(APP, '..');const NEW_IDENT_DIR = process.env.NEW_IDENT_DIR || join(PROJECTS, 'ident.worldapi.org');const OLD_IDENT_DIR = process.env.OLD_IDENT_DIR || join(PROJECTS, 'ident.worldapi.org/.scratch/pre-023-ident');const IDENT_PORT = 8724, PORT = 8725;const ID = `http://127.0.0.1:${IDENT_PORT}`;const BASE = `http://127.0.0.1:${PORT}`;const W = join(APP, '.scratch/m039-switch');const J = JSON.stringify;const sleep = (ms) => new Promise(r => setTimeout(r, ms));const SHORT = /^[2-9a-hj-km-np-z]{5}$/;const OLD = /^[0-9a-f]{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 : '')); }};let log = '';const procs = new Set();function start(label, bin, args, cwd, env) {log += `\n==== ${label}\n`;const p = spawn(bin, args, { cwd, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });p.stdout.on('data', d => log += d); p.stderr.on('data', d => log += d);procs.add(p);return p;}async function stop(p) {if (!p) return;try { p.kill('SIGTERM'); } catch {}await new Promise(r => { if (p.exitCode !== null || p.signalCode !== null) return r(); p.once('exit', r); setTimeout(r, 3000); });procs.delete(p);}async function up(url, what) {for (let i = 0; i < 120; i++) { try { const r = await fetch(url, { redirect: 'manual' }); if (r.status < 500) return; } catch {} await sleep(250); }throw new Error(what + ' did not come up\n' + log.slice(-3000));}// ---- ident (old or new code, on a given data dir; codes go to a mail sink — never real mail) -------------------async function startIdentOn(codeDir, dataDir) {mkdirSync(dataDir, { recursive: true });const sink = join(dataDir, 'mail.txt');if (!existsSync(sink)) writeFileSync(sink, '');const p = start('ident ' + codeDir, join(codeDir, 'bin/hybriel'), ['project.hl'], codeDir, {IDENT_PORT: String(IDENT_PORT), IDENT_STORAGE: join(dataDir, 'mpackdb'), IDENT_SESSIONS: join(dataDir, 'sessions') + '/',IDENT_MAIL_SINK: sink, IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000', IDENT_WATCH: '0', HL_HOST: '127.0.0.1',SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '' });await up(ID + '/', 'ident');return { p, sink };}let iemitI = 0;async function identEmit(event, payload, cookie) {const r = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++iemitI, event, payload }) });const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}return { value: j && j.value, raw: t, setCookie: (r.headers.get('set-cookie') || '').split(';')[0] };}async function identSignIn(sink, email) {const c = await fetch(ID + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email }) });if (c.status !== 200) throw new Error('ident code refused: ' + c.status);const code = readFileSync(sink, 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' ')).pop().split(' ')[1];const v = await identEmit('verifyCode', [email, code, 'Europe/Vienna']);if (!v.value || !v.value.account) throw new Error('ident sign-in failed: ' + v.raw);return { email, cookie: v.setCookie, identities: v.value.identities };}// the selector's path: identities (as the app's page asks) → choose → one-time codeasync function selectorCode(who, app, identityName = null) {const l = await (await fetch(ID + '/api/selector/identities?key=' + app.key, { headers: { origin: BASE, cookie: who.cookie } })).json();if (!l.identities || !l.identities.length) throw new Error('no identities: ' + J(l));const pick = identityName ? l.identities.find(i => i.name === identityName) : l.identities[0];if (!pick) throw new Error('no identity named ' + identityName + ': ' + J(l));const c = await (await fetch(ID + '/api/selector/choose?key=' + app.key, { method: 'POST', headers: { origin: BASE, cookie: who.cookie, 'content-type': 'application/json' }, body: J({ identity: pick.id }) })).json();if (!c.code) throw new Error('choose failed: ' + J(c));return c.code;}// ---- the app -----------------------------------------------------------------------------------------------------async function startApp(codeDir, store, app, extra = {}) {const p = start('gitoria.worldapi.org on ' + store, join(codeDir, 'bin/hybriel'), ['project.hl'], codeDir, {...appEnv(store), HL_HOST: '127.0.0.1', IDENT_URL: ID, IDENT_EXCHANGE_URL: ID, IDENT_API_KEY: app.key, IDENT_API_SECRET: app.secret, ...extra });await up(BASE + '/', 'gitoria.worldapi.org');return p;}// the login button's / selector's landing: <app>/login/callback?ident_code= → the app's session cookieasync function appLogin(who, app, identityName = null, cookie = null) {const code = await selectorCode(who, app, identityName);const r = await fetch(BASE + '/login/callback?ident_code=' + code, { redirect: 'manual', headers: cookie ? { cookie } : {} });const loc = r.headers.get('location') || '';if (r.status !== 302 || loc.startsWith('/login/failed')) {let why = r.status !== 302 ? (await r.text()).replace(/<(style|script)[\s\S]*?<\/(style|script)>/g, " ").replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) : '';const sc = (r.headers.get('set-cookie') || '').split(';')[0] || cookie;if (r.status === 302) try { why = await (await fetch(BASE + '/login/failed', { headers: sc ? { cookie: sc } : {} })).text(); why = (why.match(/login failed[^<]*|ident refused[^<]*|that is not[^<]*/i) || [why.slice(0, 200)])[0]; } catch {}return { error: 'login failed (' + r.status + ' → ' + loc + ') ' + why };}return { cookie: (r.headers.get('set-cookie') || '').split(';')[0] || cookie };}let aemitI = 0;async function appEmit(cookie, event, payload) {const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: ++aemitI, event, payload }) });const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch {}return j && j.value !== undefined ? j.value : { error: 'no value: ' + t.slice(0, 300) };}const runTool = (store, app) => execFileSync(BIN, ['tools/migrate-short-ids.hl'], { cwd: APP, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],env: { ...process.env, ...appEnv(store), IDENT_EXCHANGE_URL: ID, IDENT_URL: ID, IDENT_API_KEY: app.key, IDENT_API_SECRET: app.secret } });const counts = (out) => { const m = out.match(/users seen (\d+), mapped (\d+), already short (\d+), unmapped (\d+), conflicts (\d+), failed (\d+)/); return m ? m.slice(1).map(Number) : null; };// ---- gitoria: what a user has — the display name, repos (owner), access tokens (user); checked through the faces and// git's own push handshake (/<slug>.git/info/refs?service=git-receive-pack answers 200 only to a token of the OWNER) ----import { request as httpRequest } from 'node:http';const appEnv = (store) => ({ GITORIA_PORT: String(PORT), GITORIA_STORAGE: join(store, 'mpackdb'), GITORIA_SESSIONS: join(store, 'sessions') + '/', GITORIA_GIT: join(store, 'git'),GITORIA_WATCH: '0', GITORIA_PUBLIC_URL: BASE, GITORIA_TICKETS_URL: 'http://127.0.0.1:1', GITORIA_SSH_SECRET: '' });const prepareCode = async () => APP;const firstEnv = async () => ({});const laterEnv = async () => ({});const toolExtra = async () => {};const hostOf = (slug) => `${slug}.127.0.0.1:${PORT}`;const pushAsk = (slug, token) => new Promise((res, rej) => {const rq = httpRequest({ host: '127.0.0.1', port: PORT, path: `/${slug}.git/info/refs?service=git-receive-pack`, method: 'GET',headers: { host: hostOf(slug), authorization: 'Basic ' + Buffer.from('x:' + token).toString('base64') } }, (r) => { r.resume(); r.on('end', () => res(r.statusCode)); });rq.on('error', rej); rq.end();});async function makeData(ca, cb, cw) {const out = {};for (const [who, cookie, name, slug] of [['alice', ca, 'Alice', 'alice-m039'], ['bob', cb, 'Bob', 'bob-m039'], ['work', cw, 'Workname', null]]) {const n = await appEmit(cookie, 'gitoriaSaveName', [name]);const r = slug ? await appEmit(cookie, 'gitoriaCreate', [slug, 'repo of ' + name]) : null;const t = await appEmit(cookie, 'gitoriaMakeToken', ['laptop']);out[who] = { name, slug, token: t.token, nameOk: n && n.name === name && !n.error, repoOk: !slug || (r && !r.error) };}out.alice.other = out.bob.slug; out.bob.other = out.alice.slug;const own = await pushAsk('alice-m039', out.alice.token), foreign = await pushAsk('alice-m039', out.bob.token);check('old world: three users with names and tokens, two repos; the push handshake: owner\'s token 200, another\'s 403',['alice', 'bob', 'work'].every(w => out[w].nameOk && out[w].repoOk && /^gtr_/.test(out[w].token || '')) && own === 200 && foreign === 403, J([out, own, foreign]).slice(0, 600));return out;}async function isNewEmptyUser(cookie) {const n = await appEmit(cookie, 'gitoriaSaveName', ['Alice again']);return n && !n.error && n.name === 'Alice again'; // a new user: no name yet}async function sameUser(label, cookie, who) {const n = await appEmit(cookie, 'gitoriaSaveName', ['Someone else']);const t = await appEmit(cookie, 'gitoriaMakeToken', ['after the switch']);const fresh = t && t.token ? await pushAsk(who.slug, t.token) : 0;const old = await pushAsk(who.slug, who.token);const other = t && t.token ? await pushAsk(who.other, t.token) : 0;check(label + ': the SAME user — name kept (' + who.name + '), a token made now may push to the OWN old repo (200), not to the other\'s (403), the old token still works',n && /already set/.test(n.error || '') && fresh === 200 && old === 200 && other === 403, J([n, fresh, old, other]));}let identP = null, appP = null;try {rmSync(W, { recursive: true, force: true });mkdirSync(W, { recursive: true });const oldCode = join(W, 'ident-old-code'), newCode = join(W, 'ident-new-code');copyIdent(OLD_IDENT_DIR, oldCode);copyIdent(NEW_IDENT_DIR, newCode);const oldIsOld = !readFileSync(join(oldCode, 'store.hl'), 'utf8').includes('shortId');const newIsNew = readFileSync(join(newCode, 'project.hl'), 'utf8').includes('migrate-ids');check('the old ident has no short ids, the new one has /api/migrate-ids', oldIsOld && newIsNew);const CODE = await prepareCode();// ---- 1. the OLD world -------------------------------------------------------------------------------------------console.log('# 1. old ident + gitoria.worldapi.org, data made');let id = await startIdentOn(oldCode, join(W, 'ident-data'));identP = id.p;const alice = await identSignIn(id.sink, '[email protected]');const bob = await identSignIn(id.sink, '[email protected]');const added = await identEmit('addIdentity', [{ identityName: 'Work' }], alice.cookie);const work = added.value && added.value.identities ? added.value.identities.find(i => i.identityName === 'Work') : null;const reg = await identEmit('appCreate', [{ name: 'gitoria.worldapi.org', origins: [BASE] }], alice.cookie);if (!reg.value || !reg.value.secret) throw new Error('appCreate failed: ' + reg.raw);const app = { key: reg.value.app.apiKey, secret: reg.value.secret };writeFileSync(join(W, 'test-app-key.json'), J(app)); // the THROW-AWAY test app's key (for a by-hand rerun of the tool)appP = await startApp(CODE, join(W, 'app-store'), app, await firstEnv());const a1 = await appLogin(alice, app), b1 = await appLogin(bob, app);const w1 = await appLogin(alice, app, 'Work');check('old ident: alice, bob and alice\'s "Work" log in to gitoria.worldapi.org', !a1.error && !b1.error && !w1.error && work != null, J([a1.error, b1.error, w1.error]));const before = await makeData(a1.cookie, b1.cookie, w1.cookie);await stop(appP); appP = null;const dropped = await identEmit('dropIdentity', [work.id], alice.cookie);check('alice deletes her "Work" identity in ident (its app user becomes UNMAPPED)', dropped.value && !dropped.value.error, dropped.raw.slice(0, 200));await stop(identP); identP = null;// ---- 2. copies --------------------------------------------------------------------------------------------------cpSync(join(W, 'ident-data'), join(W, 'ident-data-new'), { recursive: true });cpSync(join(W, 'app-store'), join(W, 'app-store-control'), { recursive: true });cpSync(join(W, 'app-store'), join(W, 'app-store-migrated'), { recursive: true });// ---- 3. the NEW ident on the copy -------------------------------------------------------------------------------console.log('# 2. new ident (ident#23) on a copy of the old ident store');id = await startIdentOn(newCode, join(W, 'ident-data-new'));identP = id.p;const mig = await (await fetch(ID + '/api/migrate-ids', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J(app) })).json();const olds = Object.keys(mig.ids || {});check('new ident: migrate-ids maps 2 old ids (alice Default, bob) to short ids; the deleted identity is left out; not finished',olds.length === 2 && olds.every(k => OLD.test(k)) && Object.values(mig.ids).every(v => SHORT.test(v)) && mig.finished === false, J(mig));// ---- 4. CONTROL: unmigrated store ---------------------------------------------------------------------------------console.log('# 3. control: the app on an UNMIGRATED copy');appP = await startApp(CODE, join(W, 'app-store-control'), app, await laterEnv(mig));const c1 = await appLogin(alice, app);if (c1.error) check('control: login through the new ident fails or …', false, c1.error);else check('control (not migrated): alice comes back as a NEW, EMPTY user — this is what the migration prevents', await isNewEmptyUser(c1.cookie, before), '');await stop(appP); appP = null;// ---- 5. the migration, twice --------------------------------------------------------------------------------------console.log('# 4. tools/migrate-short-ids.hl on the other copy, twice');const r1 = runTool(join(W, 'app-store-migrated'), app);writeFileSync(join(W, 'migrate-run1.txt'), r1);console.log(r1.trim().split('\n').map(l => ' | ' + l).join('\n'));check('run 1: users seen 3, mapped 2, already short 0, unmapped 1 (deleted identity), conflicts 0, failed 0', J(counts(r1)) === J([3, 2, 0, 1, 0, 0]), J(counts(r1)));const shorts = [...r1.matchAll(/MAPPED user \S+ → (\S+)/g)].map(m => m[1]).sort();check('run 1: the users now hold exactly the short ids ident mapped', J(shorts) === J(Object.values(mig.ids).sort()), J([shorts, mig.ids]));check('run 1 never prints the key or the secret', !r1.includes(app.secret) && !r1.includes(app.key));const r2 = runTool(join(W, 'app-store-migrated'), app);writeFileSync(join(W, 'migrate-run2.txt'), r2);check('run 2 (idempotent): seen 3, mapped 0, already short 2, unmapped 1', J(counts(r2)) === J([3, 0, 2, 1, 0, 0]), J(counts(r2)));await toolExtra(r1, r2, mig, app, alice);const badSecret = (() => { try { runTool(join(W, 'app-store-migrated'), { key: app.key, secret: 'sk_' + '0'.repeat(48) }); return 'ran'; } catch (e) { return String(e.stdout || '') + String(e.stderr || ''); } })();// the control copy: the app ran with the new ident BEFORE the migration and made a new user for alice's short idconst rc = (() => { try { return 'exit 0: ' + runTool(join(W, 'app-store-control'), app); } catch (e) { return String(e.stdout || '') + String(e.stderr || ''); } })();writeFileSync(join(W, 'migrate-control.txt'), rc);check('the tool on the control copy (app ran before the migration): alice = CONFLICT, left alone, exit non-zero',J(counts(rc)) === J([4, 1, 1, 1, 1, 0]) && /CONFLICT user/.test(rc) && /CONFLICTS:/.test(rc) && !rc.startsWith('exit 0'), rc.slice(0, 400));check('a wrong secret: the tool stops with ident\'s 401, changes nothing', /401/.test(badSecret), badSecret.slice(0, 300));// ---- 6. the app on the migrated store ------------------------------------------------------------------------------console.log('# 5. the app on the MIGRATED copy with the new ident');appP = await startApp(CODE, join(W, 'app-store-migrated'), app, await laterEnv(mig));await sameUser('alice, OLD app session (no new login)', a1.cookie, before.alice);const a2 = await appLogin(alice, app);check('alice logs in again through the new ident', !a2.error, a2.error);if (!a2.error) await sameUser('alice, fresh login through the new ident', a2.cookie, before.alice, mig);const b2 = await appLogin(bob, app);check('bob logs in again through the new ident', !b2.error, b2.error);if (!b2.error) await sameUser('bob, fresh login through the new ident', b2.cookie, before.bob, mig);await stop(appP); appP = null;const r3 = runTool(join(W, 'app-store-migrated'), app);writeFileSync(join(W, 'migrate-run3.txt'), r3);check('after the logins: still 3 users (no new one was made), all short or unmapped as before', J(counts(r3)) === J([3, 0, 2, 1, 0, 0]), J(counts(r3)));await stop(identP); identP = null;} catch (err) {failed++; console.log(' FAIL (aborted) ' + (err.stack || err));} finally {for (const p of [...procs]) await stop(p);mkdirSync(W, { recursive: true });writeFileSync(join(W, 'servers.log'), log);}console.log(`\n${passed} passed, ${failed} failed`);process.exit(failed ? 1 : 0);
Branches
- mainmain branch
Latest commits
- 110c2799mission 002 (code order) 1/4: .hl files out of the root — lib/ (api, git, localtime, markdown, repos, sshgate, sshkeys, tickets, tokens, transport, users), components/styles.hl; jsoncheck.hl removed (imported nowhere); import paths only. gates 200/0, 46/0, 44/0; real-data reads + writes identicalmre
- fdfb4b1bgitoria: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 200/0, 46/0, 44/0mre
- 5b46ac84antcolony#40: LOG.md — missions 069/072 are antcolony missions (report paths on Byrodin)mre
- 5602ff41gitoria: Hybriel master 190aa11d (fc838894 GC correctness, #127 mountKids by reference, #126, #48) — tracker README flat; gates 200/0, 46/0, 44/0mre
- e85eaf01gitoria: 069 round 2 — hybriel 1a096ad3 not adopted (Markdown SSR still grows); browser gate waits for the server-side logout before restartmre
- 09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre
- 3dc43108antcolony#40: mission references point to the moved missionsmre
- 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
- 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
- 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
- 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
- fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
- 4a2d7125initial commitmre