ident
All repositories: gitoria
32.0 KB
// tests/apps.mjs — THE GATE OF PIECE 2 (ticket #25): apps, one id per app, the login// button, the exchange. Real headless Chrome (tests/cdp.mjs, --disable-gpu) drives:// register apps in ident → set up the test app → "Log in with ident" → ident login →// choose identity → back → exchange → the test app shows the id. Plus the negatives.//// Own servers only, NEVER the dev server and NEVER real mail:// ident :8370 (IDENT_MAIL_SINK), ident :8371 (grant TTL 1.5 s, for the expiry case),// test app A :8372, test app B :8373 (both against ident :8370).// Own storage: .scratch/apps-gate/ (wiped at start). Chrome debug ports 8670-8679.// Screenshots: .scratch/apps-*.png (390 and 1280 px) — look at them.//// Run: node tests/apps.mjs (exit 0 = all passed)import { spawn } from 'node:child_process';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';const APP = join(dirname(fileURLToPath(import.meta.url)), '..');const G = join(APP, '.scratch/apps-gate');const SHOTS = join(APP, '.scratch');const ID = 'http://127.0.0.1:8370', IDS = 'http://127.0.0.1:8371';const TA = 'http://127.0.0.1:8372', TB = 'http://127.0.0.1:8373';const CHROME_PORTS = [8670, 8679];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, out: () => out });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);}function stopAll() { for (const { p } of procs) { try { p.kill(); } catch {} } }rmSync(G, { recursive: true, force: true });mkdirSync(G, { recursive: true });const identEnv = (port, dir, extra = {}) => ({IDENT_PORT: String(port), IDENT_STORAGE: join(G, dir, 'ident'), IDENT_SESSIONS: join(G, dir, 'sess') + '/',IDENT_MAIL_SINK: join(G, dir, 'mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000', ...extra,});mkdirSync(join(G, 'main'), { recursive: true }); mkdirSync(join(G, 'short'), { recursive: true });start(APP, identEnv(8370, 'main'), 'ident-main.log');start(APP, identEnv(8371, 'short', { IDENT_GRANT_TTL_MS: '1500' }), 'ident-short.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(8372, 'testapp-a.json'); testapp(8373, 'testapp-b.json');await Promise.all([up(ID), up(IDS), up(TA), up(TB)]);// ---- helpers ----------------------------------------------------------------------------const lastCode = (dir, email) => {const lines = readFileSync(join(G, dir, '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(base, event, payload, cookie) {const r = await fetch(base + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify({ t: 'emit', i: ++emitI, event, payload }) });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, cookie: (r.headers.get('set-cookie') || '').split(';')[0], value: j && j.value, raw: t };}async function exchange(base, body) {const r = await fetch(base + '/api/exchange', { method: 'POST', headers: { 'content-type': 'application/json' }, body: typeof body === 'string' ? body : JSON.stringify(body) });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, j, t };}// log in over the emit API (for the non-browser parts): answers the cookie; the account's// identities (oldest first; ids are mpackdb UUIDs since mission 009) land in idsOf[cookie]const idsOf = {};// (mission 010: the code is requested with POST /api/code — no longer a face; the session// cookie comes from the verifyCode frame, which mints it)async function apiLogin(base, dir, email) {const q = await fetch(base + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }) });if (q.status !== 200) throw new Error('code request failed: ' + q.status + ' ' + await q.text());const v = await emit(base, 'verifyCode', [email, lastCode(dir, email), 'UTC']);if (!v.value || !v.value.account || !v.cookie) throw new Error('api login failed: ' + v.raw);idsOf[v.cookie] = v.value.identities.map(i => i.id);return v.cookie;}// the ids a signed-in page renders in its buttons' value attributes (SSR), in order, once eachasync function ssrIds(base, path, cookie) {const t = await (await fetch(base + path, { headers: { cookie } })).text();return [...new Set([...t.matchAll(/value="([0-9a-z]{12})"/g)].map(m => m[1]))];}// a login button press + choice over HTTP: answers the one-time codeasync function codeFor(base, cookie, key, returnUrl, identity) {const l = await fetch(base + '/login?key=' + key + '&return=' + encodeURIComponent(returnUrl), { redirect: 'manual' });const rid = (l.headers.get('location') || '').split('/').pop();const c = await emit(base, 'chooseIdentity', [rid, identity], cookie);if (!c.value || !c.value.url) throw new Error('choose failed: ' + c.raw);return new URL(c.value.url).searchParams.get('ident_code');}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(150);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, `apps-${name}-${w}.png`), Buffer.from(data, 'base64'));}await viewport(p, 1280);}// confirm() answers: the next dialog is accepted (true) or dismissed (false)function onDialogs(p) {p.dialogAnswers = [];p.dialogs = [];p.conn.onEvent((msg) => {if (msg.sessionId !== p.sessionId || msg.method !== 'Page.javascriptDialogOpening') return;p.dialogs.push(msg.params.message);const accept = p.dialogAnswers.length ? p.dialogAnswers.shift() : false;p.send('Page.handleJavaScriptDialog', { accept }).catch(() => {});});}const here = (p) => p.evaluate('location.href');const txt = (p, sel) => p.evaluate(`(document.querySelector(${JSON.stringify(sel)}) || {}).textContent || ''`);async function browserLogin(p, email) {await p.waitForSelector('#email');await ready(p);await p.type('#email', email);await p.click('#sendcode');// ident#20: a sent code navigates to the code page (/code, /signin/<rid>/code)await p.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'code page' });await ready(p);await p.type('#code', lastCode('main', email));await p.click('#verify');}// ident#20: a REAL reload (Page.reload), then hydratedasync function reload(p) {p._loaded = false;await p.send('Page.reload', { ignoreCache: false });const deadline = Date.now() + 15000;while (!p._loaded && Date.now() < deadline) await sleep(25);if (!p._loaded) throw new Error('reload: load event never fired');await ready(p);}let browser1, browser2;try {browser1 = await launchBrowser({ debugPortRange: CHROME_PORTS });browser2 = await launchBrowser({ debugPortRange: CHROME_PORTS });const p = await browser1.newPage(); pages.push(p); onDialogs(p);await viewport(p, 1280);// ==== 1. sign in to ident, register two apps ==========================================console.log('# apps: register, list, secret shown once');const ALICE = '[email protected]';await p.goto(ID + '/apps');check('signed out /apps asks to sign in', /sign in/i.test(await txt(p, '#signinfirst')));await p.goto(ID + '/');await browserLogin(p, ALICE);await p.waitForSelector('#skip');await p.click('#skip');await p.waitFor('!document.querySelector("#identityform")');await p.goto(ID + '/apps');await p.waitForSelector('#newapp'); await ready(p);check('no apps yet', /No apps yet/.test(await txt(p, '#noapps')));const register = async (name, origins) => {await p.click('#newapp');await p.waitForSelector('#appform');await p.type('#fappname', name);await p.type('#forigins', origins);await p.click('#saveapp');await p.waitFor('!!document.querySelector("#secret") || /\\S/.test(document.querySelector("#message").textContent)');};// a bad origin first: refused, namedawait register('Bad', 'http://127.0.0.1:8372/callback');check('origin with a path refused', /no path/.test(await txt(p, '#message')), await txt(p, '#message'));await p.click('#cancelapp');await register('Test app A', 'http://127.0.0.1:8372');const secretA = (await txt(p, '#secret')).trim();check('secret shown after register (sk_ + 48 hex)', /^sk_[0-9a-f]{48}$/.test(secretA), secretA);const keyA = (await txt(p, '#apps li:nth-child(1) .apikey')).trim();check('API key listed (pk_ + 32 hex)', /^pk_[0-9a-f]{32}$/.test(keyA), keyA);// hybriel#121 (mission 048): a face taking the session syncs the session-derived list back — a row added on top would show twicecheck('first app listed ONCE (no session-sync double row)', (await p.evaluate('document.querySelectorAll("#apps li").length')) === 1, await txt(p, '#apps'));await shot(p, 'secret');await p.click('#secretdone');await p.waitFor('!document.querySelector("#secretbox")');await p.goto(ID + '/apps'); await ready(p);check('secret not shown again after reload', !(await p.evaluate('document.body.textContent.includes(' + JSON.stringify(secretA) + ')')));await register('Test app B', 'http://127.0.0.1:8373, http://localhost:8373');const secretB = (await txt(p, '#secret')).trim();const keyB = (await txt(p, '#apps li:nth-child(2) .apikey')).trim();check('second app: own key and secret', keyB !== keyA && secretB !== secretA && /^pk_/.test(keyB));check('second app: two rows, each app ONCE', (await p.evaluate('[...document.querySelectorAll("#apps li .apikey")].map(e => e.textContent.trim()).join(" ")')) === keyA + ' ' + keyB, await txt(p, '#apps'));check('origins listed', (await txt(p, '#apps li:nth-child(2)')).includes('http://127.0.0.1:8373 http://localhost:8373'));await p.click('#secretdone');await shot(p, 'list');// edit: rename B and keep one originawait p.click('#apps li:nth-child(2) .edit');await p.waitForSelector('#appform');check('edit form filled', (await p.evaluate('document.querySelector("#forigins").value')) === 'http://127.0.0.1:8373 http://localhost:8373');await p.type('#fappname', 'Test app B2', { clear: true });await p.type('#forigins', 'http://127.0.0.1:8373', { clear: true });await p.click('#saveapp');await p.waitFor('/App saved/.test(document.querySelector("#notice").textContent)');const bRow = await txt(p, '#apps li:nth-child(2)');check('edit saved (name, origins, key unchanged)', bRow.includes('Test app B2') && !bRow.includes('localhost') && bRow.includes(keyB), bRow);// ==== 2. the test apps are set up with key + secret ===================================console.log('# test app setup');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("#login") || !!document.querySelector("#result"))', { label: 'setup saved' });check('test app ' + base + ' saved key + secret', (await txt(p, '#setupkey')) === key, await txt(p, 'body'));};await setupApp(TA, keyA, secretA);check('test app A set up', (await txt(p, '#setupkey')) === keyA);await setupApp(TB, keyB, secretB);// ==== 3. the login button, signed in already, one identity ============================console.log('# login button: signed in, one identity');const pressLogin = async (base) => {await p.goto(base + '/');await p.click('#login');await p.waitFor('location.pathname.startsWith("/signin/") && !!document.querySelector("#apprequest")', { label: 'ident sign-in page' });await ready(p);};const chooseAndReturn = async (nth, base) => {await p.waitForSelector('#chooselist');await p.click(`#chooselist li:nth-child(${nth}) .choose`);await p.waitFor(`location.href.startsWith(${JSON.stringify(base + '/callback')}) && !!document.querySelector("#result")`, { label: 'back at the app' });return { identity: (await txt(p, '#identity')).trim(), state: await txt(p, '#userstate'), answer: await txt(p, '#answer'), result: await txt(p, '#result') };};await pressLogin(TA);check('ident names the app and its origin', (await txt(p, '#appname')) === 'Test app A' && (await txt(p, '#apporigin')) === 'http://127.0.0.1:8372');check('one identity: still shown, one choice', (await p.evaluate('document.querySelectorAll("#chooselist li").length')) === 1 && (await txt(p, '#chooselist li')).includes('Default'));check('identities management not shown on the app page', !(await p.evaluate('!!document.querySelector("#identitiessection")')));await shot(p, 'choose-one');const a1 = await chooseAndReturn(1, TA);check('back at test app A with an identity id', /^[2-9a-hj-km-np-z]{5}$/.test(a1.identity), JSON.stringify(a1));check('the app got ONLY the id (no email, no names)', a1.answer === JSON.stringify({ identity: a1.identity }), a1.answer);check('test app: new user', a1.state === 'new user');await shot(p, 'testapp-result');await pressLogin(TA);const a2 = await chooseAndReturn(1, TA);check('same identity, same app → same id', a2.identity === a1.identity && a2.state === 'welcome back', JSON.stringify(a2));await pressLogin(TB);const b1 = await chooseAndReturn(1, TB);check('same identity, second app → the SAME id (ident#23)', b1.identity === a1.identity, JSON.stringify([a1.identity, b1.identity]));// ==== 4. two identities: the choice matters ============================================console.log('# two identities');await p.goto(ID + '/'); await ready(p);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');await pressLogin(TA);check('two identities offered', (await p.evaluate('document.querySelectorAll("#chooselist li").length')) === 2);await shot(p, 'choose-two');const aWork = await chooseAndReturn(2, TA);check('other identity, same app → other id', /^[2-9a-hj-km-np-z]{5}$/.test(aWork.identity) && aWork.identity !== a1.identity && aWork.state === 'new user', JSON.stringify(aWork));await pressLogin(TA);const aDef = await chooseAndReturn(1, TA);check('first identity again → its old id', aDef.identity === a1.identity);// ==== 5. signed out: ident login first, then the choice ===============================console.log('# login button: signed out');const q = await browser2.newPage(); pages.push(q); onDialogs(q);await viewport(q, 1280);await q.goto(TB + '/');await q.click('#login');await q.waitFor('location.pathname.startsWith("/signin/") && !!document.querySelector("#email")', { label: 'ident login for the app' });check('signed out: login form under the app banner', (await txt(q, '#apprequest')).includes('Test app B2'));await shot(q, 'signin-for-app');const BOB = '[email protected]';// ident#20 (mission 032): the app login's code step is the page /signin/<rid>/code and// survives a reload; the code entered after it signs in and the app flow completesawait ready(q);const ridPath = await q.evaluate('location.pathname');await q.type('#email', BOB);await q.click('#sendcode');await q.waitFor(`location.pathname === ${JSON.stringify(ridPath + '/code')} && !!document.querySelector("#code")`, { label: 'app code page' });await ready(q);check('ident#20: app login: "Send me a code" → /signin/<rid>/code with the code form', /^\/signin\/[0-9a-f]{32}\/code$/.test(await q.evaluate('location.pathname')) && (await txt(q, '#sentto strong')) === BOB, await q.evaluate('location.pathname'));await reload(q);check('ident#20: app login: RELOAD of /signin/<rid>/code → code form for bob, still under the app banner', (await q.evaluate('location.pathname')) === ridPath + '/code' && (await txt(q, '#sentto strong')) === BOB && !!(await q.evaluate('!!document.querySelector("#code") && !document.querySelector("#email")')) && (await txt(q, '#apprequest')).includes('Test app B2'), await txt(q, 'ident-card'));await shot(q, 'code-for-app-reloaded');await q.type('#code', lastCode('main', BOB));await q.click('#verify');// a first login: the optional names come first, then the choiceawait q.waitForSelector('#welcome');check('ident#20: app login: signed in after the reload, address bar back on /signin/<rid>', (await q.evaluate('location.pathname')) === ridPath, await q.evaluate('location.pathname'));check('first login via an app: optional names first', /optional/.test(await txt(q, '#welcome')) && !(await q.evaluate('!!document.querySelector("#chooselist")')));await q.click('#skip');await q.waitForSelector('#chooselist');await q.click('#chooselist li:nth-child(1) .choose');await q.waitFor(`location.href.startsWith(${JSON.stringify(TB + '/callback')}) && !!document.querySelector("#identity")`);const bob = (await txt(q, '#identity')).trim();check('another account → another id', /^[2-9a-hj-km-np-z]{5}$/.test(bob) && bob !== b1.identity && bob !== a1.identity);// an existing account signs in via the app: no names form, straight to the choiceawait q.goto(ID + '/'); await ready(q);await q.click('#signout');await q.waitForSelector('#email');await q.goto(TB + '/');await q.click('#login');await q.waitFor('!!document.querySelector("#email")');// ident#20: "Other address" on the app's code page → back to /signin/<rid>, the email formawait ready(q);const ridPath2 = await q.evaluate('location.pathname');await q.type('#email', '[email protected]');await q.click('#sendcode');await q.waitFor(`location.pathname === ${JSON.stringify(ridPath2 + '/code')} && !!document.querySelector("#back")`, { label: 'app code page 2' });await ready(q);await q.click('#back');await q.waitFor(`location.pathname === ${JSON.stringify(ridPath2)} && !!document.querySelector("#email")`, { label: 'back to the app sign-in' });check('ident#20: app login: "Other address" → /signin/<rid> email form under the app banner', (await txt(q, '#apprequest')).includes('Test app B2') && !(await q.evaluate('!!document.querySelector("#code")')));await reload(q);check('ident#20: app login: … and a reload stays on the email form', (await q.evaluate('location.pathname')) === ridPath2 && (await q.evaluate('!!document.querySelector("#email") && !document.querySelector("#code")')));await q.goto(ID + ridPath2 + '/code');await q.waitForSelector('#email');check('ident#20: app login: /signin/<rid>/code with nothing pending → back to /signin/<rid>', (await q.evaluate('location.pathname')) === ridPath2);await browserLogin(q, BOB);await q.waitForSelector('#chooselist');check('known account: straight to the choice (no names form)', !(await q.evaluate('!!document.querySelector("#welcome")')));await q.click('#chooselist li:nth-child(1) .choose');await q.waitFor(`!!document.querySelector("#identity")`);check('after an OTP login: same id as before', (await txt(q, '#identity')).trim() === bob);// ==== 6. the login button refuses: error page, never a redirect =======================console.log('# login button negatives');const badLogin = async (qs, re, label) => {const before = p.messages.length;await p.goto(ID + '/login' + qs);const url = await here(p);const msg = await txt(p, '#errormessage');check(label + ' → error page, no redirect', url.startsWith(ID + '/login') && re.test(msg), url + ' | ' + msg);p.messages.splice(before); // the page's own 400 is expected in the console};await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://evil.example.org/callback'), /not one of the origins/, 'foreign origin');await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://127.0.0.1:8373/callback'), /not one of the origins/, "the other app's origin");await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://127.0.0.1:[email protected]/'), /not one of the origins/, 'user@host trick');await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('javascript:alert(1)'), /http/, 'javascript: URL');await badLogin('?key=' + keyA, /missing/, 'no return URL');await badLogin('?key=pk_00000000000000000000000000000000&return=' + encodeURIComponent(TA + '/callback'), /no app has this API key/, 'unknown key');await badLogin('?return=' + encodeURIComponent(TA + '/callback'), /missing/, 'no key');const res = await fetch(ID + '/login?key=' + keyA + '&return=' + encodeURIComponent('http://evil.example.org/'), { redirect: 'manual' });check('foreign origin: HTTP 400, no Location', res.status === 400 && !res.headers.get('location'), res.status + ' ' + res.headers.get('location'));await shot(p, 'error');await p.goto(ID + '/signin/0123456789abcdef0123456789abcdef');check('unknown request id → "expired" page', /unknown or expired/.test(await txt(p, '#badrequest')));// ==== 7. the exchange ==================================================================console.log('# exchange');const aliceCookie = await apiLogin(ID, 'main', ALICE);const aliceId = idsOf[aliceCookie][0]; // her default (oldest) identitycheck('ids: identity ids are 12-char mpackdb UUIDs', idsOf[aliceCookie].length >= 1 && idsOf[aliceCookie].every(i => /^[0-9a-z]{12}$/.test(i)), JSON.stringify(idsOf[aliceCookie]));let code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);let r = await exchange(ID, { key: keyA, secret: secretB, code });check('wrong secret → 401', r.status === 401, r.t);r = await exchange(ID, { key: 'pk_x', secret: secretA, code });check('unknown key → 401', r.status === 401, r.t);r = await exchange(ID, { key: keyA, secret: secretA, code });check('right key + secret → 200 { identity } only', r.status === 200 && JSON.stringify(Object.keys(r.j)) === '["identity"]' && r.j.identity === a1.identity, r.t);r = await exchange(ID, { key: keyA, secret: secretA, code });check('reused code → 400', r.status === 400 && /already used/.test(r.j.error), r.t);code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);r = await exchange(ID, { key: keyB, secret: secretB, code });check("other app's code → 400", r.status === 400 && /not issued to this app/.test(r.j.error), r.t);r = await exchange(ID, { key: keyA, secret: secretA, code });check('a code shown to the wrong app is spent → 400', r.status === 400, r.t);r = await exchange(ID, { key: keyA, secret: secretA, code: 'nope' });check('unknown code → 400', r.status === 400, r.t);r = await exchange(ID, '{"key": "x",');check('invalid JSON → 400', r.status === 400 && /not valid JSON/.test(r.j.error), r.t);r = await exchange(ID, '[1]');check('not an object → 400', r.status === 400 && /object/.test(r.j.error), r.t);r = await exchange(ID, { key: keyA, secret: secretA });check('missing field → 400 naming it', r.status === 400 && r.j.error === 'missing field: code', r.t);r = await exchange(ID, { key: keyA, secret: secretA, code: 'x', email: 'x' });check('unknown field → 400 naming it', r.status === 400 && r.j.error === 'unknown field: email', r.t);r = await exchange(ID, { key: keyA, secret: secretA, code: 5 });check('wrong type → 400 naming it', r.status === 400 && /field code must be a string/.test(r.j.error), r.t);r = await exchange(ID, '{"key":"\\ud83d\\ude00","secret":"a","code":"b"}');check('surrogate escape → 400 (not 500)', r.status === 400, r.t);const g = await fetch(ID + '/api/exchange');check('GET /api/exchange → 405', g.status === 405);// expiry, on the short-clock server (grant TTL 1.5 s)const sc = await apiLogin(IDS, 'short', '[email protected]');const sApp = (await emit(IDS, 'appCreate', [{ name: 'S', origins: [TA] }], sc)).value;const sCode = await codeFor(IDS, sc, sApp.app.apiKey, TA + '/callback', idsOf[sc][0]);await sleep(2000);r = await exchange(IDS, { key: sApp.app.apiKey, secret: sApp.secret, code: sCode });check('expired code → 400', r.status === 400 && /expired/.test(r.j.error), r.t);const sCode2 = await codeFor(IDS, sc, sApp.app.apiKey, TA + '/callback', idsOf[sc][0]);r = await exchange(IDS, { key: sApp.app.apiKey, secret: sApp.secret, code: sCode2 });check('fresh code on the short server → 200', r.status === 200, r.t);// ==== 8. faces: forged sessions (#31), strict fields, other accounts ==================console.log('# faces');const forged = { user: { id: 1 } };for (const [ev, args] of [['appCreate', [{ name: 'X', origins: [TA] }]], ['appUpdate', [1, { name: 'X', origins: [TA] }]], ['appNewSecret', [1]], ['appDelete', [1]], ['chooseIdentity', ['x', 1]]]) {const f = await emit(ID, ev, [...args, forged]);check(`forged session argument refused: ${ev}`, framework_refused(f.raw) || (f.value && /not signed in/.test(f.value.error)), f.raw);}let e = await emit(ID, 'appCreate', [{ name: 'X', origins: [TA], secret: 'mine' }], aliceCookie);check('appCreate unknown field → named', e.value.error === 'unknown field: secret' && e.value.field === 'secret', e.raw);e = await emit(ID, 'appCreate', [{ name: 'X' }], aliceCookie);check('appCreate missing origins → named', e.value.field === 'origins', e.raw);e = await emit(ID, 'appCreate', [{ name: '', origins: [TA] }], aliceCookie);check('appCreate empty name refused', e.value.field === 'name', e.raw);e = await emit(ID, 'appCreate', [{ name: 'X', origins: 'http://a.b' }], aliceCookie);check('appCreate origins not a list refused', e.value.field === 'origins', e.raw);e = await emit(ID, 'appCreate', [{ name: 'X', origins: Array.from({ length: 11 }, (_, i) => 'http://h' + i + '.example') }], aliceCookie);check('appCreate 11 origins refused', /at most 10/.test(e.value.error), e.raw);e = await emit(ID, 'appCreate', [{ name: 'X', origins: ['ftp://a.b'] }], aliceCookie);check('appCreate ftp origin refused', e.value.field === 'origins', e.raw);const bobCookie = await apiLogin(ID, 'main', BOB);const aliceApp = (await ssrIds(ID, '/apps', aliceCookie))[0];check('ids: app ids are 12-char mpackdb UUIDs (read off /apps)', /^[0-9a-z]{12}$/.test(aliceApp || ''), JSON.stringify(aliceApp));e = await emit(ID, 'appUpdate', [aliceApp, { name: 'hijack', origins: ['http://evil.example'] }], bobCookie);check("another account cannot edit alice's app", e.value.error === 'no such app', e.raw);e = await emit(ID, 'appNewSecret', [aliceApp], bobCookie);check("another account cannot renew alice's secret", e.value.error === 'no such app', e.raw);e = await emit(ID, 'appDelete', [aliceApp], bobCookie);check("another account cannot delete alice's app", e.value.error === 'no such app', e.raw);const l = await fetch(ID + '/login?key=' + keyA + '&return=' + encodeURIComponent(TA + '/callback'), { redirect: 'manual' });const rid = l.headers.get('location').split('/').pop();e = await emit(ID, 'chooseIdentity', [rid, aliceId], bobCookie);check("choosing another account's identity refused", e.value.error === 'no such identity', e.raw);e = await emit(ID, 'chooseIdentity', [rid, 1], aliceCookie);check('chooseIdentity wrong type (a number, the old ids) refused', /must be a string/.test(e.value.error), e.raw);e = await emit(ID, 'appUpdate', [1, { name: 'X', origins: [TA] }], aliceCookie);check('appUpdate with an old numeric id: no such app', e.value.error === 'no such app', e.raw);// ==== 9. new secret, delete (browser) ==================================================console.log('# new secret, delete');await p.goto(ID + '/apps'); await ready(p);p.dialogAnswers.push(false);await p.click('#apps li:nth-child(1) .newsecret');await sleep(300);check('new secret: dismissed confirm changes nothing', !(await p.evaluate('!!document.querySelector("#secretbox")')) && p.dialogs.length === 1);p.dialogAnswers.push(true);await p.click('#apps li:nth-child(1) .newsecret');await p.waitForSelector('#secret');const secretA2 = (await txt(p, '#secret')).trim();check('new secret shown once, differs', /^sk_[0-9a-f]{48}$/.test(secretA2) && secretA2 !== secretA);await p.click('#secretdone');code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);r = await exchange(ID, { key: keyA, secret: secretA, code });check('old secret → 401 after renewal', r.status === 401, r.t);r = await exchange(ID, { key: keyA, secret: secretA2, code });check('new secret works, same id', r.status === 200 && r.j.identity === a1.identity, r.t);// the test app still has the old secret: its exchange fails visiblyawait pressLogin(TA);await p.click('#chooselist li:nth-child(1) .choose');const beforeOld = p.messages.length;await p.waitFor('!!document.querySelector("#result")');await sleep(300);p.messages.splice(beforeOld); // the test app's own 400 page is expectedcheck('test app with the old secret: exchange refused (401)', (await txt(p, '#status')) === '401', await txt(p, '#result'));await setupApp(TA, keyA, secretA2);await pressLogin(TA);const a3 = await chooseAndReturn(1, TA);check('test app with the new secret: logged in, same id', a3.identity === a1.identity, JSON.stringify(a3));// delete app Bawait p.goto(ID + '/apps'); await ready(p);p.dialogAnswers.push(true);await p.click('#apps li:nth-child(2) .delete');await p.waitFor('document.querySelectorAll("#apps li").length === 1');check('app deleted from the list', !(await txt(p, '#apps')).includes('Test app B2'));await badLogin('?key=' + keyB + '&return=' + encodeURIComponent(TB + '/callback'), /no app has this API key/, 'deleted app key');// ==== 10. console ======================================================================const probs = pages.flatMap(x => x.problems().map(m => m.text));check('no console errors or warnings', probs.length === 0, probs.join(' | '));} catch (err) {failed++;console.log(' FAIL (aborted) ' + (err && err.stack || err));for (const x of pages) console.log('--- console:\n' + x.dumpConsole());} finally {for (const b of [browser1, browser2]) { if (b) { try { await b.close(); } catch {} } }stopAll();await sleep(300);}console.log(`\n${passed} passed, ${failed} failed`);process.exit(failed ? 1 : 0);
Branches
- mainmain branch
Latest commits
- fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
- 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