gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitff805b9aff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmreff805b9a/tests/selector.mjs

33.1 KB

  1. // tests/selector.mjs — THE GATE OF PIECE 3 (ticket #26): the identity selector. Real
  2. // headless Chrome (tests/cdp.mjs, --disable-gpu): signed in to ident on the ident origin →
  3. // the test app's page on its own origin shows <ident-selector> with the right identities →
  4. // select → one-time code → the test app's server exchanges it → the page switches to
  5. // logged in WITHOUT A RELOAD → `logged-in` → host logout resets the selector. Plus the
  6. // negatives (unregistered origin, wrong key, signed out, forged sessions #31, code reuse,
  7. // strict API) and a host restyle that must change the look.
  8. //
  9. // Own servers only, NEVER the dev server and NEVER real mail:
  10. // ident :8390 (IDENT_MAIL_SINK), test app A :8391, test app B :8392 (both against :8390),
  11. // a plain node page on :8393 = an origin NOT registered for any app.
  12. // Own storage: .scratch/selector-gate/ (wiped at start). Chrome debug ports 8690-8699.
  13. // Screenshots: .scratch/selector-*.png (390 and 1280 px) — look at them.
  14. //
  15. // Run: node tests/selector.mjs (exit 0 = all passed)
  16. import { spawn } from 'node:child_process';
  17. import { createServer } from 'node:http';
  18. import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs';
  19. import { dirname, join } from 'node:path';
  20. import { fileURLToPath } from 'node:url';
  21. import { launchBrowser, sleep } from './cdp.mjs';
  22. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  23. const APP = join(dirname(fileURLToPath(import.meta.url)), '..');
  24. const G = join(APP, '.scratch/selector-gate');
  25. const SHOTS = join(APP, '.scratch');
  26. const ID = 'http://127.0.0.1:8390';
  27. const TA = 'http://127.0.0.1:8391', TB = 'http://127.0.0.1:8392', EVIL = 'http://127.0.0.1:8393';
  28. const CHROME_PORTS = [8690, 8699];
  29. const J = JSON.stringify;
  30. let passed = 0, failed = 0;
  31. const check = (name, ok, detail = '') => {
  32. if (ok) { passed++; console.log(' ok ' + name); }
  33. else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
  34. };
  35. // ---- servers ----------------------------------------------------------------------------
  36. const procs = [];
  37. function start(cwd, env, log) {
  38. const p = spawn(join(APP, 'bin/hybriel'), ['project.hl'], {
  39. cwd, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env },
  40. stdio: ['ignore', 'pipe', 'pipe'],
  41. });
  42. let out = '';
  43. p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { out += d; });
  44. p.on('exit', () => writeFileSync(join(G, log), out));
  45. procs.push({ p, log });
  46. return p;
  47. }
  48. async function up(url) {
  49. for (let i = 0; i < 80; i++) { try { await fetch(url + '/', { redirect: 'manual' }); return; } catch {} await sleep(250); }
  50. throw new Error('server did not come up: ' + url);
  51. }
  52. rmSync(G, { recursive: true, force: true });
  53. mkdirSync(join(G, 'main'), { recursive: true });
  54. start(APP, {
  55. IDENT_PORT: '8390', IDENT_STORAGE: join(G, 'main', 'ident'), IDENT_SESSIONS: join(G, 'main', 'sess') + '/',
  56. IDENT_MAIL_SINK: join(G, 'main', 'mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000',
  57. }, 'ident.log');
  58. const testapp = (port, file) => start(join(APP, 'testapp'), {
  59. TESTAPP_PORT: String(port), TESTAPP_URL: 'http://127.0.0.1:' + port, IDENT_URL: ID, TESTAPP_STORE: join(G, file),
  60. }, 'testapp-' + port + '.log');
  61. testapp(8391, 'testapp-a.json'); testapp(8392, 'testapp-b.json');
  62. // the UNREGISTERED ORIGIN: a page that includes the selector with app A's key
  63. let evilKey = '';
  64. const evil = createServer((req, res) => {
  65. res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  66. 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>`);
  67. });
  68. await new Promise(r => evil.listen(8393, '127.0.0.1', r));
  69. await Promise.all([up(ID), up(TA), up(TB)]);
  70. // ---- helpers ----------------------------------------------------------------------------
  71. const lastCode = (email) => {
  72. const lines = readFileSync(join(G, 'main', 'mail.txt'), 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' '));
  73. return lines.length ? lines[lines.length - 1].split(' ')[1] : null;
  74. };
  75. let emitI = 0;
  76. // hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is
  77. // ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.
  78. 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; } };
  79. async function emit(event, payload, cookie) {
  80. const r = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });
  81. const t = await r.text();
  82. let j = null; try { j = JSON.parse(t); } catch {}
  83. return { status: r.status, value: j && j.value, raw: t };
  84. }
  85. // a request to ident as a browser on `origin` would send it (node may set Origin)
  86. async function call(method, path, { origin, cookie, body, headers = {} } = {}) {
  87. const h = { ...headers };
  88. if (origin) h.origin = origin;
  89. if (cookie) h.cookie = cookie;
  90. if (body !== undefined && !h['content-type']) h['content-type'] = 'application/json';
  91. const r = await fetch(ID + path, { method, headers: h, body: body === undefined ? undefined : (typeof body === 'string' ? body : J(body)), redirect: 'manual' });
  92. const t = await r.text();
  93. let j = null; try { j = JSON.parse(t); } catch {}
  94. return { status: r.status, j, t, h: r.headers };
  95. }
  96. async function exchange(body) {
  97. const r = await fetch(ID + '/api/exchange', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J(body) });
  98. return { status: r.status, j: await r.json().catch(() => null) };
  99. }
  100. const pages = [];
  101. async function ready(p) { await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'hydrated' }); }
  102. async function viewport(p, w) {
  103. await p.send('Emulation.setDeviceMetricsOverride', { width: w, height: w < 500 ? 844 : 900, deviceScaleFactor: 1, mobile: w < 500 });
  104. }
  105. async function shot(p, name) {
  106. for (const w of [390, 1280]) {
  107. await viewport(p, w);
  108. await sleep(200);
  109. const over = await p.evaluate('document.documentElement.scrollWidth > window.innerWidth');
  110. check(`${name} @${w}px: no horizontal overflow`, !over);
  111. const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  112. writeFileSync(join(SHOTS, `selector-${name}-${w}.png`), Buffer.from(data, 'base64'));
  113. }
  114. await viewport(p, 1280);
  115. }
  116. const txt = (p, sel) => p.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || ''`);
  117. // INSIDE THE SELECTOR'S SHADOW ROOT
  118. const sh = (hostSel, expr) => `(() => { const h = document.querySelector(${J(hostSel)}); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;
  119. const shText = (p, inner, host = '#selector') => p.evaluate(sh(host, `(r.querySelector(${J(inner)}) || {}).textContent || ''`));
  120. const shNames = (p, host = '#selector') => p.evaluate(sh(host, `[...r.querySelectorAll('[part~="identity"]')].map(b => b.textContent)`));
  121. // a REAL click (Input domain) on an element inside the shadow root; `name` picks an
  122. // identity button by its text
  123. async function shClick(p, inner, { host = '#selector', name = null, timeout = 8000 } = {}) {
  124. const find = `(() => { const h = document.querySelector(${J(host)}); const r = h && h.shadowRoot; if (!r) return null; ` +
  125. `const el = ${name === null ? `r.querySelector(${J(inner)})` : `[...r.querySelectorAll(${J(inner)})].find(b => b.textContent === ${J(name)})`}; ` +
  126. `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 }; })()`;
  127. const box = await p.waitFor(find, { timeout, label: `shadow ${host} ${inner} ${name || ''}` });
  128. const base = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1 };
  129. await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...base, buttons: 0 });
  130. await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...base, buttons: 1 });
  131. await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...base, buttons: 0 });
  132. }
  133. const waitList = (p, host = '#selector') => p.waitFor(sh(host, `r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'identity list in ' + host });
  134. const waitMsg = (p, re, host = '#selector') => p.waitFor(sh(host, `/${re}/.test((r.querySelector('#message') || {}).textContent || '')`), { label: 'selector message ' + re });
  135. const shStyle = (p, inner, prop) => p.evaluate(sh('#selector', `getComputedStyle(r.querySelector(${J(inner)}))[${J(prop)}]`));
  136. let browser1, browser2;
  137. try {
  138. browser1 = await launchBrowser({ debugPortRange: CHROME_PORTS });
  139. browser2 = await launchBrowser({ debugPortRange: CHROME_PORTS });
  140. const p = await browser1.newPage(); pages.push(p);
  141. await viewport(p, 1280);
  142. // ==== 1. sign in to ident (on ident's origin), two identities, two apps ================
  143. console.log('# setup: ident account, identities, apps, test apps');
  144. const ALICE = '[email protected]';
  145. await p.goto(ID + '/');
  146. await p.waitForSelector('#email'); await ready(p);
  147. await p.type('#email', ALICE);
  148. await p.click('#sendcode');
  149. await p.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'code page' });
  150. await ready(p); // ident#20: the code step is its own page (/code) — wait for it to hydrate
  151. await p.type('#code', lastCode(ALICE));
  152. await p.click('#verify');
  153. await p.waitForSelector('#skip');
  154. await p.click('#skip');
  155. await p.waitFor('!document.querySelector("#identityform")');
  156. await p.click('#newidentity');
  157. await p.waitForSelector('#identityform');
  158. await p.type('#fidentityname', 'Work');
  159. await p.click('#saveidentity');
  160. await p.waitFor('document.querySelectorAll("#identities li").length === 2');
  161. const register = async (name, origins) => {
  162. await p.goto(ID + '/apps'); await p.waitForSelector('#newapp'); await ready(p);
  163. await p.click('#newapp');
  164. await p.waitForSelector('#appform');
  165. await p.type('#fappname', name);
  166. await p.type('#forigins', origins);
  167. await p.click('#saveapp');
  168. await p.waitForSelector('#secret');
  169. const secret = (await txt(p, '#secret')).trim();
  170. const keys = await p.evaluate('[...document.querySelectorAll("#apps .apikey")].map(e => e.textContent.trim())');
  171. await p.click('#secretdone');
  172. return { key: keys[keys.length - 1], secret };
  173. };
  174. const A = await register('Selector app A', TA);
  175. const B = await register('Selector app B', TB);
  176. 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]));
  177. evilKey = A.key;
  178. const setupApp = async (base, key, secret) => {
  179. await p.goto(base + '/');
  180. await p.type('#key', key, { clear: true });
  181. await p.type('#secret', secret, { clear: true });
  182. await p.evaluate('window.__old = 1');
  183. await p.click('#savesetup');
  184. await p.waitFor('!window.__old && document.readyState === "complete" && !!document.querySelector("#setupkey")', { label: 'setup saved' });
  185. check('test app ' + base + ' set up', (await txt(p, '#setupkey')) === key);
  186. };
  187. await setupApp(TA, A.key, A.secret);
  188. await setupApp(TB, B.key, B.secret);
  189. const aliceCookie = (await p.cookies([ID])).filter(c => c.name === 'identsid').map(c => 'identsid=' + c.value)[0];
  190. check('browser holds the ident session cookie (identsid)', !!aliceCookie);
  191. // ==== 2. the selector on the test app's page: closed, open, identities ================
  192. console.log('# selector: shown, opened, identities');
  193. await p.goto(TA + '/');
  194. await p.waitFor('!!customElements.get("ident-selector") && !!document.querySelector("#selector").shadowRoot', { label: 'selector defined' });
  195. check('the page includes ident\'s script', await p.evaluate(`!![...document.scripts].find(s => s.src === ${J(ID + '/selector.js')})`));
  196. check('selector is configured with the app\'s API key', (await p.evaluate('document.querySelector("#selector").getAttribute("key")')) === A.key);
  197. check('selector first says "choose ident"', (await shText(p, '#choose')).replace(/[▼▲]/g, '').trim() === 'choose ident', await shText(p, '#choose'));
  198. check('not logged in: no logged-in attribute', !(await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in")')));
  199. check('shadow DOM in ident\'s design (accent #ce9178)', (await shStyle(p, '#choose', 'backgroundColor')) === 'rgb(206, 145, 120)', await shStyle(p, '#choose', 'backgroundColor'));
  200. await shot(p, 'closed');
  201. await shClick(p, '#choose');
  202. await waitList(p);
  203. const names = await shNames(p);
  204. check('open: the signed-in user\'s identities (identity names)', J(names) === J(['Default', 'Work']), J(names));
  205. await shot(p, 'open');
  206. // what ident answered (read by the page itself, as the script does)
  207. const listA = await p.evaluate(`fetch(${J(ID + '/api/selector/identities?key=' + A.key)}, { credentials: 'include' }).then(r => r.json())`);
  208. check('answer: only { signedIn, identities: [{ id, name }] }', listA.signedIn === true && listA.identities.every(i => J(Object.keys(i)) === '["id","name"]'), J(listA));
  209. 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));
  210. const noEmail = !J(listA).includes('alice');
  211. check('no email in the answer', noEmail);
  212. const toggleClosed = async () => { await shClick(p, '#choose'); await p.waitFor(sh('#selector', `!r.querySelector('#panel')`)); };
  213. await toggleClosed();
  214. check('clicking again closes it', true);
  215. // ==== 3. select = login, without a reload ============================================
  216. console.log('# select → code → host exchange → logged in without reload');
  217. await p.evaluate('window.__noReload = 1; window.__codes = []; document.querySelector("#selector").addEventListener("ident-login", e => window.__codes.push(e.detail.code))');
  218. const cookiesBefore = (await p.cookies([TA])).map(c => c.name).sort();
  219. await shClick(p, '#choose');
  220. await waitList(p);
  221. await shClick(p, '[part~="identity"]', { name: 'Work' });
  222. await p.waitFor('/Logged in/.test(document.querySelector("#loginstate").textContent) && !!document.querySelector("#identity")', { label: 'logged in via selector' });
  223. check('no reload happened', (await p.evaluate('window.__noReload')) === 1);
  224. const codes = await p.evaluate('window.__codes');
  225. check('ident-login event carried a one-time code (48 hex)', codes.length === 1 && /^[0-9a-f]{48}$/.test(codes[0]), J(codes));
  226. const idWork = (await txt(p, '#identity')).trim();
  227. check('host exchanged it: the identity\'s short id', /^[2-9a-hj-km-np-z]{5}$/.test(idWork), idWork);
  228. check('test app: new user', (await txt(p, '#userstate')) === 'new user');
  229. check('host set logged-in', await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in") && document.querySelector("#selector").loggedIn === true'));
  230. const status = await shText(p, '#status');
  231. 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);
  232. check('logout offered', !(await p.evaluate('document.querySelector("#logout").hidden')));
  233. const cookiesAfter = (await p.cookies([TA])).map(c => c.name).sort();
  234. 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]));
  235. await shot(p, 'loggedin');
  236. const reuse = await exchange({ key: A.key, secret: A.secret, code: codes[0] });
  237. check('code reuse: the selector\'s code a second time → 400', reuse.status === 400 && /already used/.test(reuse.j.error), J(reuse));
  238. // the host knows its session: after a reload it renders logged-in itself
  239. await p.goto(TA + '/');
  240. await p.waitFor('!!customElements.get("ident-selector")');
  241. check('reload: host renders <ident-selector logged-in>', await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in")'));
  242. check('reload: still logged in in the test app', /Logged in/.test(await txt(p, '#loginstate')));
  243. // ==== 4. host logout resets the selector =============================================
  244. console.log('# host logout');
  245. await p.evaluate('window.__noReload = 1');
  246. await p.click('#logout');
  247. await p.waitFor('/Not logged in/.test(document.querySelector("#loginstate").textContent)');
  248. check('logout: no reload', (await p.evaluate('window.__noReload')) === 1);
  249. 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')));
  250. await p.goto(TA + '/');
  251. await p.waitFor('!!customElements.get("ident-selector")');
  252. check('after logout + reload: not logged in', !(await p.evaluate('document.querySelector("#selector").hasAttribute("logged-in")')) && /Not logged in/.test(await txt(p, '#loginstate')));
  253. // ==== 5. the other identity, and the same one via the login button ===================
  254. console.log('# ids: per identity, same as the button flow');
  255. await shClick(p, '#choose');
  256. await waitList(p);
  257. await shClick(p, '[part~="identity"]', { name: 'Default' });
  258. await p.waitFor('!!document.querySelector("#identity")');
  259. const idDefault = (await txt(p, '#identity')).trim();
  260. 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]));
  261. await p.click('#logout');
  262. await p.waitFor('/Not logged in/.test(document.querySelector("#loginstate").textContent)');
  263. await p.click('#login');
  264. await p.waitForSelector('#chooselist'); await ready(p);
  265. await p.click('#chooselist li:nth-child(2) .choose');
  266. await p.waitFor(`location.href.startsWith(${J(TA + '/callback')}) && !!document.querySelector("#identity")`);
  267. check('login button with Work → the same id as the selector gave', (await txt(p, '#identity')).trim() === idWork && (await txt(p, '#userstate')) === 'welcome back');
  268. // app B: the same identity gets the same id there
  269. await p.goto(TB + '/');
  270. await shClick(p, '#choose');
  271. await waitList(p);
  272. const listB = await p.evaluate(`fetch(${J(ID + '/api/selector/identities?key=' + B.key)}, { credentials: 'include' }).then(r => r.json())`);
  273. check('list ids differ per app', listB.identities.every((x, i) => x.id !== listA.identities[i].id), J([listA, listB]));
  274. await shClick(p, '[part~="identity"]', { name: 'Work' });
  275. await p.waitFor('!!document.querySelector("#identity")');
  276. const idWorkB = (await txt(p, '#identity')).trim();
  277. check('same identity, app B → the SAME id', idWorkB === idWork);
  278. // ==== 6. restyle by the host =========================================================
  279. console.log('# restyle by the host');
  280. await p.goto(TA + '/');
  281. await p.waitFor('!!customElements.get("ident-selector")');
  282. // the login button above logged this browser in to the test app: log out first
  283. if (await p.evaluate('!document.querySelector("#logout").hidden')) {
  284. await p.click('#logout');
  285. await p.waitFor(sh('#selector', `!!r.querySelector('#choose')`), { label: 'selector reset' });
  286. }
  287. const before = { bg: await shStyle(p, '#choose', 'backgroundColor'), radius: await shStyle(p, '#choose', 'borderTopLeftRadius'), tt: await shStyle(p, '#choose', 'textTransform') };
  288. 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>')`);
  289. const after = { bg: await shStyle(p, '#choose', 'backgroundColor'), radius: await shStyle(p, '#choose', 'borderTopLeftRadius'), tt: await shStyle(p, '#choose', 'textTransform') };
  290. check('custom property --ident-accent changes the button colour', before.bg === 'rgb(206, 145, 120)' && after.bg === 'rgb(86, 155, 212)', J([before, after]));
  291. check('custom property --ident-radius changes the corners', before.radius !== '0px' && after.radius === '0px', J([before, after]));
  292. check('::part(button) restyles from the host', before.tt === 'none' && after.tt === 'uppercase', J([before, after]));
  293. await shClick(p, '#choose');
  294. await waitList(p);
  295. check('::part(identity) restyles the list', (await p.evaluate(sh('#selector', `getComputedStyle(r.querySelector('[part~="identity"]')).fontStyle`))) === 'italic');
  296. await shot(p, 'restyled-open');
  297. await p.evaluate('document.querySelector("#restyle").remove()');
  298. await toggleClosed();
  299. // ==== 7. negatives in the browser ====================================================
  300. console.log('# negatives: origin, key, signed out');
  301. // (a) an origin not registered for the app — same site, so the ident cookie DOES travel;
  302. // only the Origin check stops it
  303. await p.goto(EVIL + '/');
  304. await p.waitFor('!!customElements.get("ident-selector")');
  305. await shClick(p, '#choose');
  306. await waitMsg(p, 'does not answer this site');
  307. check('unregistered origin: no identities, an explanation', (await shNames(p)).length === 0);
  308. 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)`);
  309. check('unregistered origin: the browser gets nothing (CORS)', evilFetch === 'blocked: TypeError', evilFetch);
  310. 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)`);
  311. check('unregistered origin: choose blocked (preflight refused)', evilChoose === 'blocked: TypeError', evilChoose);
  312. await shot(p, 'unregistered');
  313. p.messages.splice(0); // the CORS refusals above are expected console errors
  314. // (b) wrong key on a registered origin: app B's key on app A's page, and a made-up key
  315. await p.goto(TA + '/');
  316. await p.waitFor('!!customElements.get("ident-selector")');
  317. const before2 = p.messages.length;
  318. for (const [label, key] of [["another app's key", B.key], ['an unknown key', 'pk_' + '0'.repeat(32)]]) {
  319. await p.evaluate(`document.querySelector("#other") && document.querySelector("#other").remove(); document.querySelector("#selectorbox").insertAdjacentHTML('beforeend', '<ident-selector id="other" key="${key}"></ident-selector>')`);
  320. await shClick(p, '#choose', { host: '#other' });
  321. await waitMsg(p, 'does not answer this site', '#other');
  322. check(`wrong key (${label}): no identities`, (await shNames(p, '#other')).length === 0);
  323. }
  324. p.messages.splice(before2);
  325. // (c) signed out of ident: the second browser has no ident session
  326. const q = await browser2.newPage(); pages.push(q);
  327. await viewport(q, 1280);
  328. await q.goto(TA + '/');
  329. await q.waitFor('!!customElements.get("ident-selector")');
  330. await shClick(q, '#choose');
  331. await waitMsg(q, 'not signed in to ident');
  332. 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 + '/');
  333. const outList = await q.evaluate(`fetch(${J(ID + '/api/selector/identities?key=' + A.key)}, { credentials: 'include' }).then(r => r.json())`);
  334. 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));
  335. await shot(q, 'signedout');
  336. // ==== 8. the API directly: CORS headers, strict body, forged sessions ================
  337. console.log('# API: CORS, strict, forged sessions');
  338. let r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: aliceCookie });
  339. 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}`);
  340. check('Vary: Origin, no Set-Cookie from the selector API', /Origin/.test(r.h.get('vary') || '') && !r.h.get('set-cookie'));
  341. r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: EVIL, cookie: aliceCookie });
  342. 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}`);
  343. r = await call('GET', '/api/selector/identities?key=' + B.key, { origin: TA, cookie: aliceCookie });
  344. 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}`);
  345. r = await call('GET', '/api/selector/identities', { origin: TA, cookie: aliceCookie });
  346. check('no key: 403', r.status === 403, r.t);
  347. r = await call('GET', '/api/selector/identities?key=' + A.key, { cookie: aliceCookie });
  348. check('no Origin header: 403', r.status === 403, r.t);
  349. r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: 'null', cookie: aliceCookie });
  350. check('Origin null: 403', r.status === 403, r.t);
  351. r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA + '.evil.example', cookie: aliceCookie });
  352. check('origin with the registered one as prefix: 403', r.status === 403, r.t);
  353. r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: 'identsid=' + 'f'.repeat(32) });
  354. check('made-up ident cookie: signed out', r.status === 200 && r.j.signedIn === false, r.t);
  355. r = await call('OPTIONS', '/api/selector/choose?key=' + A.key, { origin: TA, headers: { 'access-control-request-method': 'POST', 'access-control-request-headers': 'content-type' } });
  356. 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(' ')}`);
  357. r = await call('OPTIONS', '/api/selector/choose?key=' + A.key, { origin: EVIL });
  358. check('preflight from an unregistered origin: 403, no CORS headers', r.status === 403 && !r.h.get('access-control-allow-origin'), `${r.status}`);
  359. const pick = listA.identities[1].id; // Work
  360. const choose = (body, opt = {}) => call('POST', '/api/selector/choose?key=' + A.key, { origin: TA, cookie: aliceCookie, body, ...opt });
  361. r = await choose({ identity: pick });
  362. check('choose: 200 { code } only', r.status === 200 && J(Object.keys(r.j)) === '["code"]' && /^[0-9a-f]{48}$/.test(r.j.code), r.t);
  363. const code1 = r.j.code;
  364. let x = await exchange({ key: A.key, secret: A.secret, code: code1 });
  365. check('exchange of a selector code → the same id as in the browser', x.status === 200 && x.j.identity === idWork, J(x));
  366. x = await exchange({ key: A.key, secret: A.secret, code: code1 });
  367. check('the same code again → 400', x.status === 400, J(x));
  368. r = await choose({ identity: pick });
  369. x = await exchange({ key: B.key, secret: B.secret, code: r.j.code });
  370. 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));
  371. x = await exchange({ key: A.key, secret: A.secret, code: r.j.code });
  372. check('…and it is spent', x.status === 400, J(x));
  373. r = await choose({ identity: pick }, { origin: EVIL });
  374. check('choose from an unregistered origin: 403, no code', r.status === 403 && !r.t.includes('code"'), r.t);
  375. r = await choose({ identity: pick }, { cookie: undefined });
  376. check('choose without an ident session: 401', r.status === 401, r.t);
  377. r = await choose({ identity: pick, session: { user: { id: 1 } } }, { cookie: undefined });
  378. check('forged session in the body: refused (unknown field)', r.status === 400 && r.j.error === 'unknown field: session', r.t);
  379. r = await choose({ identity: pick, user: { id: 1 } });
  380. check('unknown field: named', r.status === 400 && r.j.error === 'unknown field: user', r.t);
  381. r = await choose({});
  382. check('missing identity: named', r.status === 400 && r.j.error === 'missing field: identity', r.t);
  383. r = await choose({ identity: 2 });
  384. check('identity of the wrong type: named', r.status === 400 && /field identity must be a string/.test(r.j.error), r.t);
  385. r = await choose('{"identity": ');
  386. check('invalid JSON: 400', r.status === 400 && /not valid JSON/.test(r.j.error), r.t);
  387. r = await choose('["x"]');
  388. check('not an object: 400', r.status === 400, r.t);
  389. // ident's own identity ids (UUIDs since mission 009) as the SSR page renders them in the Edit buttons
  390. const ownIds = [...new Set([...(await (await fetch(ID + '/', { headers: { cookie: aliceCookie } })).text()).matchAll(/value="([0-9a-z]{12})"/g)].map(m => m[1]))];
  391. check("alice's ident identity ids read off / (2, UUIDs)", ownIds.length === 2, J(ownIds));
  392. r = await choose({ identity: ownIds[0] || 'x' });
  393. check("ident's own identity id is not accepted as a pick: 400", r.status === 400 && r.j.error === 'no such identity', r.t);
  394. r = await choose({ identity: '2' });
  395. check("an old numeric identity id is not accepted as a pick: 400", r.status === 400 && r.j.error === 'no such identity', r.t);
  396. r = await choose({ identity: listB.identities[1].id });
  397. check("app B's pick on app A: 400", r.status === 400, r.t);
  398. r = await call('GET', '/api/selector/choose?key=' + A.key, { origin: TA, cookie: aliceCookie });
  399. check('GET choose: 405', r.status === 405, r.t);
  400. r = await call('POST', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: aliceCookie, body: {} });
  401. check('POST identities: 405', r.status === 405, r.t);
  402. // another account: bob's session cannot use alice's pick
  403. // (bob logs in over the emit carrier with the cookie the first frame got)
  404. // (mission 010: the code comes from POST /api/code; the verifyCode frame mints the cookie)
  405. await fetch(ID + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email: '[email protected]' }) });
  406. 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'] }) });
  407. const bCookie = (vBr.headers.get('set-cookie') || '').split(';')[0];
  408. const vBt = await vBr.text();
  409. const vB = { value: (JSON.parse(vBt) || {}).value, raw: vBt };
  410. check('bob signed in (emit carrier)', !!(vB.value && vB.value.account), vB.raw);
  411. r = await choose({ identity: pick }, { cookie: bCookie });
  412. check("another account cannot choose alice's identity", r.status === 400 && r.j.error === 'no such identity', r.t);
  413. r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: bCookie });
  414. 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);
  415. // #31: the faces still refuse a forged trailing session argument
  416. const forged = { user: { id: 1 } };
  417. 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]]]) {
  418. const f = await emit(ev, [...args, forged]);
  419. check(`forged session argument refused (#31): ${ev}`, framework_refused(f.raw) || (f.value && /not signed in/.test(f.value.error)), f.raw);
  420. }
  421. // ident sign-out in browser 1 → the selector there gets nothing either
  422. await p.goto(ID + '/'); await ready(p);
  423. await p.click('#signout');
  424. await p.waitForSelector('#email');
  425. await p.goto(TA + '/');
  426. await p.waitFor('!!customElements.get("ident-selector")');
  427. await shClick(p, '#choose');
  428. await waitMsg(p, 'not signed in to ident');
  429. check('after signing out of ident: no identities', (await shNames(p)).length === 0);
  430. r = await call('GET', '/api/selector/identities?key=' + A.key, { origin: TA, cookie: aliceCookie });
  431. check('after signing out of ident: the old cookie gets signedIn false', r.status === 200 && r.j.signedIn === false, r.t);
  432. // ==== 9. console =====================================================================
  433. const probs = pages.flatMap(pg => pg.problems().map(m => m.text));
  434. check('no console errors or warnings (besides the expected CORS refusals)', probs.length === 0, probs.join(' | '));
  435. } catch (err) {
  436. failed++;
  437. console.log(' FAIL (aborted) ' + (err && err.stack || err));
  438. for (const pg of pages) console.log('--- console:\n' + pg.dumpConsole());
  439. } finally {
  440. for (const b of [browser1, browser2]) { if (b) { try { await b.close(); } catch {} } }
  441. for (const { p } of procs) { try { p.kill(); } catch {} }
  442. evil.close();
  443. await sleep(300);
  444. }
  445. console.log(`\n${passed} passed, ${failed} failed`);
  446. process.exit(failed ? 1 : 0);

Branches

Latest commits

  • 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