ident
All repositories: gitoria
33.1 KB
// tests/selector.mjs — THE GATE OF PIECE 3 (ticket #26): the identity selector. Real// headless Chrome (tests/cdp.mjs, --disable-gpu): signed in to ident on the ident origin →// the test app's page on its own origin shows <ident-selector> with the right identities →// select → one-time code → the test app's server exchanges it → the page switches to// logged in WITHOUT A RELOAD → `logged-in` → host logout resets the selector. Plus the// negatives (unregistered origin, wrong key, signed out, forged sessions #31, code reuse,// strict API) and a host restyle that must change the look.//// Own servers only, NEVER the dev server and NEVER real mail:// ident :8390 (IDENT_MAIL_SINK), test app A :8391, test app B :8392 (both against :8390),// a plain node page on :8393 = an origin NOT registered for any app.// Own storage: .scratch/selector-gate/ (wiped at start). Chrome debug ports 8690-8699.// Screenshots: .scratch/selector-*.png (390 and 1280 px) — look at them.//// Run: node tests/selector.mjs (exit 0 = all passed)import { spawn } from 'node:child_process';import { createServer } from 'node:http';import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs';import { dirname, join } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser, sleep } from './cdp.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const APP = join(dirname(fileURLToPath(import.meta.url)), '..');const G = join(APP, '.scratch/selector-gate');const SHOTS = join(APP, '.scratch');const ID = 'http://127.0.0.1:8390';const TA = 'http://127.0.0.1:8391', TB = 'http://127.0.0.1:8392', EVIL = 'http://127.0.0.1:8393';const CHROME_PORTS = [8690, 8699];const J = JSON.stringify;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 : '')); }};// ---- servers ----------------------------------------------------------------------------const procs = [];function start(cwd, env, log) {const p = spawn(join(APP, 'bin/hybriel'), ['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, log });return p;}async function up(url) {for (let i = 0; i < 80; i++) { try { await fetch(url + '/', { redirect: 'manual' }); return; } catch {} await sleep(250); }throw new Error('server did not come up: ' + url);}rmSync(G, { recursive: true, force: true });mkdirSync(join(G, 'main'), { recursive: true });start(APP, {IDENT_PORT: '8390', IDENT_STORAGE: join(G, 'main', 'ident'), IDENT_SESSIONS: join(G, 'main', 'sess') + '/',IDENT_MAIL_SINK: join(G, 'main', 'mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000',}, 'ident.log');const testapp = (port, file) => start(join(APP, 'testapp'), {TESTAPP_PORT: String(port), TESTAPP_URL: 'http://127.0.0.1:' + port, IDENT_URL: ID, TESTAPP_STORE: join(G, file),}, 'testapp-' + port + '.log');testapp(8391, 'testapp-a.json'); testapp(8392, 'testapp-b.json');// the UNREGISTERED ORIGIN: a page that includes the selector with app A's keylet evilKey = '';const evil = createServer((req, res) => {res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });res.end(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>not registered</title></head><body style="background:#222;color:#ccc;font:16px system-ui"><p>an origin not registered in ident</p><ident-selector id="selector" key="${evilKey}"></ident-selector><script src="${ID}/selector.js"></script></body></html>`);});await new Promise(r => evil.listen(8393, '127.0.0.1', r));await Promise.all([up(ID), up(TA), up(TB)]);// ---- helpers ----------------------------------------------------------------------------const lastCode = (email) => {const lines = readFileSync(join(G, 'main', 'mail.txt'), 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' '));return lines.length ? lines[lines.length - 1].split(' ')[1] : null;};let emitI = 0;// hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is// ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.const framework_refused = (raw) => { try { const j = JSON.parse(raw); return j.ok === false && /the `session` parameter is filled by the server/.test(j.error || ''); } catch { return false; } };async function emit(event, payload, cookie) {const r = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, value: j && j.value, raw: t };}// a request to ident as a browser on `origin` would send it (node may set Origin)async function call(method, path, { origin, cookie, body, headers = {} } = {}) {const h = { ...headers };if (origin) h.origin = origin;if (cookie) h.cookie = cookie;if (body !== undefined && !h['content-type']) h['content-type'] = 'application/json';const r = await fetch(ID + path, { method, headers: h, body: body === undefined ? undefined : (typeof body === 'string' ? body : J(body)), redirect: 'manual' });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, j, t, h: r.headers };}async function exchange(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().catch(() => null) };}const pages = [];async function ready(p) { await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'hydrated' }); }async function viewport(p, w) {await p.send('Emulation.setDeviceMetricsOverride', { width: w, height: w < 500 ? 844 : 900, deviceScaleFactor: 1, mobile: w < 500 });}async function shot(p, name) {for (const w of [390, 1280]) {await viewport(p, w);await sleep(200);const over = await p.evaluate('document.documentElement.scrollWidth > window.innerWidth');check(`${name} @${w}px: no horizontal overflow`, !over);const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });writeFileSync(join(SHOTS, `selector-${name}-${w}.png`), Buffer.from(data, 'base64'));}await viewport(p, 1280);}const txt = (p, sel) => p.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || ''`);// INSIDE THE SELECTOR'S SHADOW ROOTconst sh = (hostSel, expr) => `(() => { const h = document.querySelector(${J(hostSel)}); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;const shText = (p, inner, host = '#selector') => p.evaluate(sh(host, `(r.querySelector(${J(inner)}) || {}).textContent || ''`));const shNames = (p, host = '#selector') => p.evaluate(sh(host, `[...r.querySelectorAll('[part~="identity"]')].map(b => b.textContent)`));// a REAL click (Input domain) on an element inside the shadow root; `name` picks an// identity button by its textasync function shClick(p, inner, { host = '#selector', name = null, timeout = 8000 } = {}) {const find = `(() => { const h = document.querySelector(${J(host)}); const r = h && h.shadowRoot; if (!r) return null; ` +`const el = ${name === null ? `r.querySelector(${J(inner)})` : `[...r.querySelectorAll(${J(inner)})].find(b => b.textContent === ${J(name)})`}; ` +`if (!el) return null; el.scrollIntoView({ block: 'center' }); const b = el.getBoundingClientRect(); if (!b.width) return null; return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; })()`;const box = await p.waitFor(find, { timeout, label: `shadow ${host} ${inner} ${name || ''}` });const base = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1 };await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...base, buttons: 0 });await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...base, buttons: 1 });await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...base, buttons: 0 });}const waitList = (p, host = '#selector') => p.waitFor(sh(host, `r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'identity list in ' + host });const waitMsg = (p, re, host = '#selector') => p.waitFor(sh(host, `/${re}/.test((r.querySelector('#message') || {}).textContent || '')`), { label: 'selector message ' + re });const shStyle = (p, inner, prop) => p.evaluate(sh('#selector', `getComputedStyle(r.querySelector(${J(inner)}))[${J(prop)}]`));let browser1, browser2;try {browser1 = await launchBrowser({ debugPortRange: CHROME_PORTS });browser2 = await launchBrowser({ debugPortRange: CHROME_PORTS });const p = await browser1.newPage(); pages.push(p);await viewport(p, 1280);// ==== 1. sign in to ident (on ident's origin), two identities, two apps ================console.log('# setup: ident account, identities, apps, test apps');const ALICE = '[email protected]';await p.goto(ID + '/');await p.waitForSelector('#email'); await ready(p);await p.type('#email', ALICE);await p.click('#sendcode');await p.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'code page' });await ready(p); // ident#20: the code step is its own page (/code) — wait for it to hydrateawait p.type('#code', lastCode(ALICE));await p.click('#verify');await p.waitForSelector('#skip');await p.click('#skip');await p.waitFor('!document.querySelector("#identityform")');await p.click('#newidentity');await p.waitForSelector('#identityform');await p.type('#fidentityname', 'Work');await p.click('#saveidentity');await p.waitFor('document.querySelectorAll("#identities li").length === 2');const register = async (name, origins) => {await p.goto(ID + '/apps'); await p.waitForSelector('#newapp'); await ready(p);await p.click('#newapp');await p.waitForSelector('#appform');await p.type('#fappname', name);await p.type('#forigins', origins);await p.click('#saveapp');await p.waitForSelector('#secret');const secret = (await txt(p, '#secret')).trim();const keys = await p.evaluate('[...document.querySelectorAll("#apps .apikey")].map(e => e.textContent.trim())');await p.click('#secretdone');return { key: keys[keys.length - 1], secret };};const A = await register('Selector app A', TA);const B = await register('Selector app B', TB);check('two apps registered', /^pk_[0-9a-f]{32}$/.test(A.key) && /^pk_[0-9a-f]{32}$/.test(B.key) && A.key !== B.key, J([A.key, B.key]));evilKey = A.key;const setupApp = async (base, key, secret) => {await p.goto(base + '/');await p.type('#key', key, { clear: true });await p.type('#secret', secret, { clear: true });await p.evaluate('window.__old = 1');await p.click('#savesetup');await p.waitFor('!window.__old && document.readyState === "complete" && !!document.querySelector("#setupkey")', { label: 'setup saved' });check('test app ' + base + ' set up', (await txt(p, '#setupkey')) === key);};await setupApp(TA, A.key, A.secret);await setupApp(TB, B.key, B.secret);const aliceCookie = (await p.cookies([ID])).filter(c => c.name === 'identsid').map(c => 'identsid=' + c.value)[0];check('browser holds the ident session cookie (identsid)', !!aliceCookie);// ==== 2. the selector on the test app's page: closed, open, identities ================console.log('# selector: shown, opened, identities');await p.goto(TA + '/');await p.waitFor('!!customElements.get("ident-selector") && !!document.querySelector("#selector").shadowRoot', { label: 'selector defined' });check('the page includes ident\'s script', await p.evaluate(`!![...document.scripts].find(s => s.src === ${J(ID + '/selector.js')})`));check('selector is configured with the app\'s API key', (await p.evaluate('document.querySelector("#selector").getAttribute("key")')) === A.key);check('selector first says "choose ident"', (await shText(p, '#choose')).replace(/[▼▲]/g, '').trim() === 'choose ident', await shText(p, '#choose'));check('not logged in: no logged-in attribute', !(await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in")')));check('shadow DOM in ident\'s design (accent #ce9178)', (await shStyle(p, '#choose', 'backgroundColor')) === 'rgb(206, 145, 120)', await shStyle(p, '#choose', 'backgroundColor'));await shot(p, 'closed');await shClick(p, '#choose');await waitList(p);const names = await shNames(p);check('open: the signed-in user\'s identities (identity names)', J(names) === J(['Default', 'Work']), J(names));await shot(p, 'open');// what ident answered (read by the page itself, as the script does)const listA = await p.evaluate(`fetch(${J(ID + '/api/selector/identities?key=' + A.key)}, { credentials: 'include' }).then(r => r.json())`);check('answer: only { signedIn, identities: [{ id, name }] }', listA.signedIn === true && listA.identities.every(i => J(Object.keys(i)) === '["id","name"]'), J(listA));check('list ids are opaque (32 hex, not ident\'s identity ids)', listA.identities.every(i => /^[0-9a-f]{32}$/.test(i.id)), J(listA));const noEmail = !J(listA).includes('alice');check('no email in the answer', noEmail);const toggleClosed = async () => { await shClick(p, '#choose'); await p.waitFor(sh('#selector', `!r.querySelector('#panel')`)); };await toggleClosed();check('clicking again closes it', true);// ==== 3. select = login, without a reload ============================================console.log('# select → code → host exchange → logged in without reload');await p.evaluate('window.__noReload = 1; window.__codes = []; document.querySelector("#selector").addEventListener("ident-login", e => window.__codes.push(e.detail.code))');const cookiesBefore = (await p.cookies([TA])).map(c => c.name).sort();await shClick(p, '#choose');await waitList(p);await shClick(p, '[part~="identity"]', { name: 'Work' });await p.waitFor('/Logged in/.test(document.querySelector("#loginstate").textContent) && !!document.querySelector("#identity")', { label: 'logged in via selector' });check('no reload happened', (await p.evaluate('window.__noReload')) === 1);const codes = await p.evaluate('window.__codes');check('ident-login event carried a one-time code (48 hex)', codes.length === 1 && /^[0-9a-f]{48}$/.test(codes[0]), J(codes));const idWork = (await txt(p, '#identity')).trim();check('host exchanged it: the identity\'s short id', /^[2-9a-hj-km-np-z]{5}$/.test(idWork), idWork);check('test app: new user', (await txt(p, '#userstate')) === 'new user');check('host set logged-in', await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in") && document.querySelector("#selector").loggedIn === true'));const status = await shText(p, '#status');check('selector shows the logged-in state with the identity', /logged in with ident/.test(status) && /Work/.test(status) && !(await p.evaluate(sh('#selector', `!!r.querySelector('#choose')`))), status);check('logout offered', !(await p.evaluate('document.querySelector("#logout").hidden')));const cookiesAfter = (await p.cookies([TA])).map(c => c.name).sort();check('the selector set no cookie (only the test app\'s own session cookie is new)', J(cookiesAfter.filter(n => !cookiesBefore.includes(n))) === J(['testapp8391sid']), J([cookiesBefore, cookiesAfter]));await shot(p, 'loggedin');const reuse = await exchange({ key: A.key, secret: A.secret, code: codes[0] });check('code reuse: the selector\'s code a second time → 400', reuse.status === 400 && /already used/.test(reuse.j.error), J(reuse));// the host knows its session: after a reload it renders logged-in itselfawait p.goto(TA + '/');await p.waitFor('!!customElements.get("ident-selector")');check('reload: host renders <ident-selector logged-in>', await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in")'));check('reload: still logged in in the test app', /Logged in/.test(await txt(p, '#loginstate')));// ==== 4. host logout resets the selector =============================================console.log('# host logout');await p.evaluate('window.__noReload = 1');await p.click('#logout');await p.waitFor('/Not logged in/.test(document.querySelector("#loginstate").textContent)');check('logout: no reload', (await p.evaluate('window.__noReload')) === 1);check('logout: selector reset (no logged-in, says "choose ident" again)', !(await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in")')) && /choose/.test(await shText(p, '#choose')));await p.goto(TA + '/');await p.waitFor('!!customElements.get("ident-selector")');check('after logout + reload: not logged in', !(await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in")')) && /Not logged in/.test(await txt(p, '#loginstate')));// ==== 5. the other identity, and the same one via the login button ===================console.log('# ids: per identity, same as the button flow');await shClick(p, '#choose');await waitList(p);await shClick(p, '[part~="identity"]', { name: 'Default' });await p.waitFor('!!document.querySelector("#identity")');const idDefault = (await txt(p, '#identity')).trim();check('other identity → other id, new user', /^[2-9a-hj-km-np-z]{5}$/.test(idDefault) && idDefault !== idWork && (await txt(p, '#userstate')) === 'new user', J([idDefault, idWork]));await p.click('#logout');await p.waitFor('/Not logged in/.test(document.querySelector("#loginstate").textContent)');await p.click('#login');await p.waitForSelector('#chooselist'); await ready(p);await p.click('#chooselist li:nth-child(2) .choose');await p.waitFor(`location.href.startsWith(${J(TA + '/callback')}) && !!document.querySelector("#identity")`);check('login button with Work → the same id as the selector gave', (await txt(p, '#identity')).trim() === idWork && (await txt(p, '#userstate')) === 'welcome back');// app B: the same identity gets the same id thereawait p.goto(TB + '/');await shClick(p, '#choose');await waitList(p);const listB = await p.evaluate(`fetch(${J(ID + '/api/selector/identities?key=' + B.key)}, { credentials: 'include' }).then(r => r.json())`);check('list ids differ per app', listB.identities.every((x, i) => x.id !== listA.identities[i].id), J([listA, listB]));await shClick(p, '[part~="identity"]', { name: 'Work' });await p.waitFor('!!document.querySelector("#identity")');const idWorkB = (await txt(p, '#identity')).trim();check('same identity, app B → the SAME id', idWorkB === idWork);// ==== 6. restyle by the host =========================================================console.log('# restyle by the host');await p.goto(TA + '/');await p.waitFor('!!customElements.get("ident-selector")');// the login button above logged this browser in to the test app: log out firstif (await p.evaluate('!document.querySelector("#logout").hidden')) {await p.click('#logout');await p.waitFor(sh('#selector', `!!r.querySelector('#choose')`), { label: 'selector reset' });}const before = { bg: await shStyle(p, '#choose', 'backgroundColor'), radius: await shStyle(p, '#choose', 'borderTopLeftRadius'), tt: await shStyle(p, '#choose', 'textTransform') };await p.evaluate(`document.head.insertAdjacentHTML('beforeend', '<style id="restyle">#selector { --ident-accent: rgb(86, 155, 212); --ident-radius: 0px; } #selector::part(button) { text-transform: uppercase; } #selector::part(identity) { font-style: italic; }</style>')`);const after = { bg: await shStyle(p, '#choose', 'backgroundColor'), radius: await shStyle(p, '#choose', 'borderTopLeftRadius'), tt: await shStyle(p, '#choose', 'textTransform') };check('custom property --ident-accent changes the button colour', before.bg === 'rgb(206, 145, 120)' && after.bg === 'rgb(86, 155, 212)', J([before, after]));check('custom property --ident-radius changes the corners', before.radius !== '0px' && after.radius === '0px', J([before, after]));check('::part(button) restyles from the host', before.tt === 'none' && after.tt === 'uppercase', J([before, after]));await shClick(p, '#choose');await waitList(p);check('::part(identity) restyles the list', (await p.evaluate(sh('#selector', `getComputedStyle(r.querySelector('[part~="identity"]')).fontStyle`))) === 'italic');await shot(p, 'restyled-open');await p.evaluate('document.querySelector("#restyle").remove()');await toggleClosed();// ==== 7. negatives in the browser ====================================================console.log('# negatives: origin, key, signed out');// (a) an origin not registered for the app — same site, so the ident cookie DOES travel;// only the Origin check stops itawait p.goto(EVIL + '/');await p.waitFor('!!customElements.get("ident-selector")');await shClick(p, '#choose');await waitMsg(p, 'does not answer this site');check('unregistered origin: no identities, an explanation', (await shNames(p)).length === 0);const evilFetch = await p.evaluate(`fetch(${J(ID + '/api/selector/identities?key=' + A.key)}, { credentials: 'include' }).then(r => 'got ' + r.status, e => 'blocked: ' + e.name)`);check('unregistered origin: the browser gets nothing (CORS)', evilFetch === 'blocked: TypeError', evilFetch);const evilChoose = await p.evaluate(`fetch(${J(ID + '/api/selector/choose?key=' + A.key)}, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identity: ${J(listA.identities[0].id)} }) }).then(r => 'got ' + r.status, e => 'blocked: ' + e.name)`);check('unregistered origin: choose blocked (preflight refused)', evilChoose === 'blocked: TypeError', evilChoose);await shot(p, 'unregistered');p.messages.splice(0); // the CORS refusals above are expected console errors// (b) wrong key on a registered origin: app B's key on app A's page, and a made-up keyawait p.goto(TA + '/');await p.waitFor('!!customElements.get("ident-selector")');const before2 = p.messages.length;for (const [label, key] of [["another app's key", B.key], ['an unknown key', 'pk_' + '0'.repeat(32)]]) {await p.evaluate(`document.querySelector("#other") && document.querySelector("#other").remove(); document.querySelector("#selectorbox").insertAdjacentHTML('beforeend', '<ident-selector id="other" key="${key}"></ident-selector>')`);await shClick(p, '#choose', { host: '#other' });await waitMsg(p, 'does not answer this site', '#other');check(`wrong key (${label}): no identities`, (await shNames(p, '#other')).length === 0);}p.messages.splice(before2);// (c) signed out of ident: the second browser has no ident sessionconst q = await browser2.newPage(); pages.push(q);await viewport(q, 1280);await q.goto(TA + '/');await q.waitFor('!!customElements.get("ident-selector")');await shClick(q, '#choose');await waitMsg(q, 'not signed in to ident');check('signed out of ident: no identities, a link to sign in to ident', (await shNames(q)).length === 0 && (await q.evaluate(sh('#selector', `r.querySelector('#signin').href`))) === ID + '/');const outList = await q.evaluate(`fetch(${J(ID + '/api/selector/identities?key=' + A.key)}, { credentials: 'include' }).then(r => r.json())`);check('signed out: ident answers { signedIn: false, identities: [] }', outList.signedIn === false && Array.isArray(outList.identities) && outList.identities.length === 0 && Object.keys(outList).length === 2, J(outList));await shot(q, 'signedout');// ==== 8. the API directly: CORS headers, strict body, forged sessions ================console.log('# API: CORS, strict, forged sessions');let r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: aliceCookie });check('registered origin + key: 200, ACAO = that exact origin, credentials true', r.status === 200 && r.h.get('access-control-allow-origin') === TA && r.h.get('access-control-allow-credentials') === 'true' && r.j.identities.length === 2, `${r.status} ${r.h.get('access-control-allow-origin')} ${r.t}`);check('Vary: Origin, no Set-Cookie from the selector API', /Origin/.test(r.h.get('vary') || '') && !r.h.get('set-cookie'));r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: EVIL, cookie: aliceCookie });check('unregistered origin: 403, no CORS headers, no identities', r.status === 403 && !r.h.get('access-control-allow-origin') && !r.t.includes('Work'), `${r.status} ${r.t}`);r = await call('GET', '/api/selector/identities?key=' + B.key, { origin: TA, cookie: aliceCookie });check("another app's key on this origin: 403, no CORS headers", r.status === 403 && !r.h.get('access-control-allow-origin'), `${r.status} ${r.t}`);r = await call('GET', '/api/selector/identities', { origin: TA, cookie: aliceCookie });check('no key: 403', r.status === 403, r.t);r = await call('GET', '/api/selector/identities?key=' + A.key, { cookie: aliceCookie });check('no Origin header: 403', r.status === 403, r.t);r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: 'null', cookie: aliceCookie });check('Origin null: 403', r.status === 403, r.t);r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA + '.evil.example', cookie: aliceCookie });check('origin with the registered one as prefix: 403', r.status === 403, r.t);r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: 'identsid=' + 'f'.repeat(32) });check('made-up ident cookie: signed out', r.status === 200 && r.j.signedIn === false, r.t);r = await call('OPTIONS', '/api/selector/choose?key=' + A.key, { origin: TA, headers: { 'access-control-request-method': 'POST', 'access-control-request-headers': 'content-type' } });check('preflight from a registered origin: 204 + allow POST, Content-Type', r.status === 204 && r.h.get('access-control-allow-origin') === TA && /POST/.test(r.h.get('access-control-allow-methods')) && /Content-Type/i.test(r.h.get('access-control-allow-headers')), `${r.status} ${[...r.h].join(' ')}`);r = await call('OPTIONS', '/api/selector/choose?key=' + A.key, { origin: EVIL });check('preflight from an unregistered origin: 403, no CORS headers', r.status === 403 && !r.h.get('access-control-allow-origin'), `${r.status}`);const pick = listA.identities[1].id; // Workconst choose = (body, opt = {}) => call('POST', '/api/selector/choose?key=' + A.key, { origin: TA, cookie: aliceCookie, body, ...opt });r = await choose({ identity: pick });check('choose: 200 { code } only', r.status === 200 && J(Object.keys(r.j)) === '["code"]' && /^[0-9a-f]{48}$/.test(r.j.code), r.t);const code1 = r.j.code;let x = await exchange({ key: A.key, secret: A.secret, code: code1 });check('exchange of a selector code → the same id as in the browser', x.status === 200 && x.j.identity === idWork, J(x));x = await exchange({ key: A.key, secret: A.secret, code: code1 });check('the same code again → 400', x.status === 400, J(x));r = await choose({ identity: pick });x = await exchange({ key: B.key, secret: B.secret, code: r.j.code });check("a selector code for app A presented by app B → 400", x.status === 400 && /not issued to this app/.test(x.j.error), J(x));x = await exchange({ key: A.key, secret: A.secret, code: r.j.code });check('…and it is spent', x.status === 400, J(x));r = await choose({ identity: pick }, { origin: EVIL });check('choose from an unregistered origin: 403, no code', r.status === 403 && !r.t.includes('code"'), r.t);r = await choose({ identity: pick }, { cookie: undefined });check('choose without an ident session: 401', r.status === 401, r.t);r = await choose({ identity: pick, session: { user: { id: 1 } } }, { cookie: undefined });check('forged session in the body: refused (unknown field)', r.status === 400 && r.j.error === 'unknown field: session', r.t);r = await choose({ identity: pick, user: { id: 1 } });check('unknown field: named', r.status === 400 && r.j.error === 'unknown field: user', r.t);r = await choose({});check('missing identity: named', r.status === 400 && r.j.error === 'missing field: identity', r.t);r = await choose({ identity: 2 });check('identity of the wrong type: named', r.status === 400 && /field identity must be a string/.test(r.j.error), r.t);r = await choose('{"identity": ');check('invalid JSON: 400', r.status === 400 && /not valid JSON/.test(r.j.error), r.t);r = await choose('["x"]');check('not an object: 400', r.status === 400, r.t);// ident's own identity ids (UUIDs since mission 009) as the SSR page renders them in the Edit buttonsconst ownIds = [...new Set([...(await (await fetch(ID + '/', { headers: { cookie: aliceCookie } })).text()).matchAll(/value="([0-9a-z]{12})"/g)].map(m => m[1]))];check("alice's ident identity ids read off / (2, UUIDs)", ownIds.length === 2, J(ownIds));r = await choose({ identity: ownIds[0] || 'x' });check("ident's own identity id is not accepted as a pick: 400", r.status === 400 && r.j.error === 'no such identity', r.t);r = await choose({ identity: '2' });check("an old numeric identity id is not accepted as a pick: 400", r.status === 400 && r.j.error === 'no such identity', r.t);r = await choose({ identity: listB.identities[1].id });check("app B's pick on app A: 400", r.status === 400, r.t);r = await call('GET', '/api/selector/choose?key=' + A.key, { origin: TA, cookie: aliceCookie });check('GET choose: 405', r.status === 405, r.t);r = await call('POST', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: aliceCookie, body: {} });check('POST identities: 405', r.status === 405, r.t);// another account: bob's session cannot use alice's pick// (bob logs in over the emit carrier with the cookie the first frame got)// (mission 010: the code comes from POST /api/code; the verifyCode frame mints the cookie)await fetch(ID + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email: '[email protected]' }) });const vBr = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ t: 'emit', i: ++emitI, event: 'verifyCode', payload: ['[email protected]', lastCode('[email protected]'), 'UTC'] }) });const bCookie = (vBr.headers.get('set-cookie') || '').split(';')[0];const vBt = await vBr.text();const vB = { value: (JSON.parse(vBt) || {}).value, raw: vBt };check('bob signed in (emit carrier)', !!(vB.value && vB.value.account), vB.raw);r = await choose({ identity: pick }, { cookie: bCookie });check("another account cannot choose alice's identity", r.status === 400 && r.j.error === 'no such identity', r.t);r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: bCookie });check("another account sees only its own identities", r.status === 200 && r.j.identities.length === 1 && r.j.identities[0].name === 'Default' && r.j.identities[0].id !== listA.identities[0].id, r.t);// #31: the faces still refuse a forged trailing session argumentconst forged = { user: { id: 1 } };for (const [ev, args] of [['addIdentity', [{ identityName: 'X' }]], ['editIdentity', [1, { identityName: 'X' }]], ['dropIdentity', [2]], ['changeTimeZone', ['UTC']], ['chooseIdentity', ['x', 1]], ['appCreate', [{ name: 'X', origins: [TA] }]], ['appNewSecret', [1]], ['appDelete', [1]]]) {const f = await emit(ev, [...args, forged]);check(`forged session argument refused (#31): ${ev}`, framework_refused(f.raw) || (f.value && /not signed in/.test(f.value.error)), f.raw);}// ident sign-out in browser 1 → the selector there gets nothing eitherawait p.goto(ID + '/'); await ready(p);await p.click('#signout');await p.waitForSelector('#email');await p.goto(TA + '/');await p.waitFor('!!customElements.get("ident-selector")');await shClick(p, '#choose');await waitMsg(p, 'not signed in to ident');check('after signing out of ident: no identities', (await shNames(p)).length === 0);r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: aliceCookie });check('after signing out of ident: the old cookie gets signedIn false', r.status === 200 && r.j.signedIn === false, r.t);// ==== 9. console =====================================================================const probs = pages.flatMap(pg => pg.problems().map(m => m.text));check('no console errors or warnings (besides the expected CORS refusals)', probs.length === 0, probs.join(' | '));} catch (err) {failed++;console.log(' FAIL (aborted) ' + (err && err.stack || err));for (const pg of pages) console.log('--- console:\n' + pg.dumpConsole());} finally {for (const b of [browser1, browser2]) { if (b) { try { await b.close(); } catch {} } }for (const { p } of procs) { try { p.kill(); } catch {} }evil.close();await sleep(300);}console.log(`\n${passed} passed, ${failed} failed`);process.exit(failed ? 1 : 0);
Branches
- mainmain branch
Latest commits
- d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
- 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
- f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
- ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
- a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
- 98226b41antcolony#40: mission references point to the moved missionsmre
- ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
- 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