gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitd2e7f91bd2e7f91bident 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 outputmred2e7f91b/tests/browser.mjs

58.8 KB

  1. // tests/browser.mjs — THE GATE of ident.worldapi.org (piece 1 of 6, ticket #24: account,
  2. // identities, time zone). Starts its OWN servers on their own storage (never touches the
  3. // dev server on :8351 or its data):
  4. // ident :8356 (IDENT_STORAGE .scratch/gate-store/main, mail sink main-mail.txt)
  5. // ident short :8357 (OTP TTL 3 s — the expiry case)
  6. // and TWO real headless Chromes (debug ports 8640-8649 / 8650-8659, --disable-gpu, killed
  7. // by PID at the end). Every login is typed and clicked in the browser; the one-time code
  8. // is read from the mail sink file like a person reads their mail. Each browser runs in an
  9. // emulated time zone (A: Pacific/Auckland, B: Asia/Tokyo) so "the browser's
  10. // time zone at first use" is observable.
  11. //
  12. // node tests/browser.mjs
  13. // IDENT_GATE_PORT=8732 IDENT_GATE_SHORT_PORT=8733 IDENT_GATE_CHROME=8734-8739 node tests/browser.mjs
  14. // (other ports: the two servers, and the Chromes' debug ports split in two halves A/B)
  15. //
  16. // Screenshots at 390px and 1280px land in .scratch/gate-*.png — LOOK at them. Server
  17. // logs: .scratch/gate-{main,short}.log. The face negatives go over the framework's REST
  18. // carrier (POST /__hl/emit, the same frame the page sends) with and without a cookie.
  19. import { spawn } from 'node:child_process';
  20. import { rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
  21. import { dirname, join, resolve } from 'node:path';
  22. import { fileURLToPath } from 'node:url';
  23. import { deflateSync } from 'node:zlib';
  24. import { launchBrowser } from './cdp.mjs';
  25. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  26. const HERE = dirname(fileURLToPath(import.meta.url));
  27. const APP = resolve(HERE, '..');
  28. const BIN = join(APP, 'bin/hybriel');
  29. const SCRATCH = join(APP, '.scratch');
  30. const STORE = join(SCRATCH, 'gate-store');
  31. rmSync(STORE, { recursive: true, force: true });
  32. mkdirSync(STORE, { recursive: true });
  33. const P_MAIN = Number(process.env.IDENT_GATE_PORT || 8356), P_SHORT = Number(process.env.IDENT_GATE_SHORT_PORT || 8357);
  34. const CHROME = (process.env.IDENT_GATE_CHROME || '8640-8659').split('-').map(Number);
  35. const CHROME_MID = Math.floor((CHROME[0] + CHROME[1]) / 2);
  36. const IDENT = `http://127.0.0.1:${P_MAIN}`;
  37. const SHORT = `http://localhost:${P_SHORT}`;
  38. const SINK_MAIN = join(STORE, 'main-mail.txt');
  39. const SINK_SHORT = join(STORE, 'short-mail.txt');
  40. const TZ_A = 'Pacific/Auckland', TZ_B = 'Asia/Tokyo';
  41. let failures = 0, passes = 0;
  42. function check(label, ok, detail = '') {
  43. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  44. if (ok) passes++; else failures++;
  45. }
  46. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  47. const J = JSON.stringify;
  48. // ---- the servers ------------------------------------------------------------------------
  49. const procs = [];
  50. function start(name, env) {
  51. let log = '';
  52. const p = spawn(BIN, ['project.hl'], { cwd: APP, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
  53. p.stdout.on('data', d => log += d); p.stderr.on('data', d => log += d);
  54. p.on('exit', () => writeFileSync(join(SCRATCH, `gate-${name}.log`), log));
  55. procs.push({ name, p, log: () => log });
  56. return p;
  57. }
  58. start('main', { IDENT_PORT: String(P_MAIN), IDENT_STORAGE: join(STORE, 'main'), IDENT_SESSIONS: join(STORE, 'main-sessions'), IDENT_MAIL_SINK: SINK_MAIN, IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000' });
  59. start('short', { IDENT_PORT: String(P_SHORT), IDENT_STORAGE: join(STORE, 'short'), IDENT_SESSIONS: join(STORE, 'short-sessions'), IDENT_MAIL_SINK: SINK_SHORT, IDENT_OTP_TTL_MS: '3000', IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000' });
  60. // the latest code the sink holds for an address (one "<address> <code>" line per mail)
  61. function mails(sink, email) {
  62. if (!existsSync(sink)) return [];
  63. return readFileSync(sink, 'utf8').split('\n').filter(l => l.startsWith(email + ' ')).map(l => l.split(' ')[1]);
  64. }
  65. const lastCode = (sink, email) => mails(sink, email).at(-1);
  66. const req = async (method, url, { raw, headers = {} } = {}) => {
  67. const r = await fetch(url, { method, redirect: 'manual', headers, body: raw });
  68. const text = await r.text();
  69. let json = null; try { json = JSON.parse(text); } catch {}
  70. return { status: r.status, json, text, location: r.headers.get('location') };
  71. };
  72. // A FACE CALL over the framework's REST carrier: the frame the page itself sends
  73. let frameNo = 0;
  74. // hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is
  75. // ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.
  76. 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; } };
  77. async function face(event, payload, cookie) {
  78. const r = await req('POST', IDENT + '/__hl/emit', { raw: J({ t: 'emit', i: ++frameNo, event, payload }), headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) } });
  79. return r.json && r.json.ok ? r.json.value : { transport: r.status, text: r.text.slice(0, 300), refused: framework_refused(r.text) };
  80. }
  81. const connected = (page, label) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label });
  82. async function viewport(page, width, height) {
  83. await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });
  84. await sleep(250);
  85. }
  86. async function shot(page, name) {
  87. const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  88. writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));
  89. }
  90. const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');
  91. async function bothWidths(page, name) {
  92. await viewport(page, 390, 844); await shot(page, 'phone-' + name);
  93. check(`layout: 390px ${name} has no horizontal overflow`, await noOverflow(page));
  94. await viewport(page, 1280, 900); await shot(page, 'desktop-' + name);
  95. check(`layout: 1280px ${name} has no horizontal overflow`, await noOverflow(page));
  96. }
  97. const msg = (page) => page.text('#message');
  98. const waitMsg = (page, re, label) => page.waitFor(`/${re}/.test((document.querySelector('#message') || {}).textContent || '')`, { label });
  99. const waitNotice = (page, re, label) => page.waitFor(`/${re}/.test((document.querySelector('#notice') || {}).textContent || '')`, { label });
  100. // the identity list as the page shows it: [{ label, details, isDefault, edit, del }]
  101. const listed = (page) => page.evaluate(`[...document.querySelectorAll('#identities li')].map(li => ({ label: li.querySelector('identity-label').textContent, details: li.querySelector('identity-details').textContent, isDefault: !!li.querySelector('default-badge'), edit: !!li.querySelector('button.edit'), del: !!li.querySelector('button.delete') }))`);
  102. const labels = async (page) => (await listed(page)).map(r => r.label);
  103. // click a row's button by the row's label
  104. async function rowButton(page, label, cls) {
  105. const sel = await page.evaluate(`(() => { const li = [...document.querySelectorAll('#identities li')].find(li => li.querySelector('identity-label').textContent === ${J(label)}); if (!li) return null; const b = li.querySelector('button.${cls}'); if (!b) return null; b.id = 'gate-target'; return '#gate-target'; })()`);
  106. if (!sel) throw new Error(`no ${cls} button for ${label}`);
  107. await page.click(sel);
  108. await page.evaluate(`document.querySelector('#gate-target') && document.querySelector('#gate-target').removeAttribute('id')`);
  109. }
  110. // window.confirm: answer the NEXT dialog of this page with `accept`
  111. function dialogs(page) {
  112. page.dialogAnswer = true;
  113. page.dialogTexts = [];
  114. page.conn.onEvent(m => {
  115. if (m.method === 'Page.javascriptDialogOpening' && m.sessionId === page.sessionId) {
  116. page.dialogTexts.push(m.params.message);
  117. page.send('Page.handleJavaScriptDialog', { accept: page.dialogAnswer }).catch(() => {});
  118. }
  119. });
  120. }
  121. // THE LOGIN AS A PERSON DOES IT: type the address, click, read the mail, type the code
  122. async function askCode(page, email) {
  123. await page.waitForSelector('#email');
  124. await connected(page, 'socket before asking');
  125. await page.type('#email', email, { clear: true });
  126. await page.evaluate('document.querySelector("#message").textContent = ""');
  127. await page.click('#sendcode');
  128. // ident#20: a sent code NAVIGATES to the code page (/code); a refusal stays with a message
  129. await page.waitFor('(/\\/code$/.test(location.pathname) && !!document.querySelector("#code")) || /\\S/.test(document.querySelector("#message").textContent)', { label: 'code page or message after ' + email });
  130. if (await page.evaluate('/\\/code$/.test(location.pathname)')) await connected(page, 'code page hydrated');
  131. }
  132. // A REAL RELOAD (Page.reload, like a phone reloading the tab), then the hydrated socket
  133. async function reload(page) {
  134. page._loaded = false;
  135. await page.send('Page.reload', { ignoreCache: false });
  136. const deadline = Date.now() + 15000;
  137. while (!page._loaded && Date.now() < deadline) await sleep(25);
  138. if (!page._loaded) throw new Error('reload: load event never fired');
  139. await connected(page, 'socket after reload');
  140. }
  141. // what the page shows: '<path> code:<address>' | '<path> email' | '<path> signedin' | '<path> ?'
  142. const step = (page) => page.evaluate(`location.pathname + ' ' + (document.querySelector('#codeform') && !document.querySelector('#emailform') ? 'code:' + document.querySelector('#sentto strong').textContent : (document.querySelector('#emailform') && !document.querySelector('#codeform') ? 'email' : (document.querySelector('#meemail') ? 'signedin' : '?')))`);
  143. // what the SERVER answers a plain GET with this cookie (no JS, no redirect following):
  144. // '302 <location>' or '200 code:<address>' / '200 email' / '200 signedin'
  145. async function ssr(path, cookie, base = IDENT) {
  146. const r = await fetch(base + path, { redirect: 'manual', headers: cookie ? { cookie } : {} });
  147. if (r.status >= 300 && r.status < 400) return r.status + ' ' + r.headers.get('location');
  148. const t = await r.text();
  149. const m = /id="sentto"[^>]*>.*?<strong[^>]*>([^<]*)</s.exec(t);
  150. return r.status + ' ' + (t.includes('id="codeform"') && m ? 'code:' + m[1] : (t.includes('id="emailform"') ? 'email' : (t.includes('id="meemail"') ? 'signedin' : '?')));
  151. }
  152. const cookieOf = async (page, url) => (await page.cookies([url])).filter(c => c.name === 'identsid').map(c => `identsid=${c.value}`)[0];
  153. async function enterCode(page, code) {
  154. await page.evaluate('document.querySelector("#message").textContent = ""');
  155. await page.type('#code', code, { clear: true });
  156. await page.click('#verify');
  157. }
  158. async function fill(page, values) {
  159. for (const [id, v] of Object.entries(values)) await page.type('#' + id, v, { clear: true });
  160. }
  161. // a PNG 300×200 (left half red, right half blue) and a text file, for the avatar upload
  162. function makePng() {
  163. const W = 300, H = 200, raw = Buffer.alloc((W * 3 + 1) * H);
  164. for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const o = y * (W * 3 + 1); raw[o] = 0; raw[o + 1 + x * 3] = x < W / 2 ? 255 : 0; raw[o + 2 + x * 3] = 0; raw[o + 3 + x * 3] = x < W / 2 ? 0 : 255; }
  165. const crcT = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crcT[n] = c >>> 0; }
  166. const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcT[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
  167. const chunk = (t, d) => { const len = Buffer.alloc(4); len.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([len, td, c]); };
  168. const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 2;
  169. return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
  170. }
  171. const PNG_FILE = join(STORE, 'avatar.png'), TXT_FILE = join(STORE, 'avatar.txt');
  172. writeFileSync(PNG_FILE, makePng()); writeFileSync(TXT_FILE, 'not a picture');
  173. // pick a file in an <input type=file> like a person does (the browser's own file chooser result)
  174. async function upload(page, selector, file) {
  175. const { root } = await page.send('DOM.getDocument');
  176. const { nodeId } = await page.send('DOM.querySelector', { nodeId: root.nodeId, selector });
  177. await page.send('DOM.setFileInputFiles', { files: [file], nodeId });
  178. }
  179. // a PASTE (one input event with the whole value), unlike page.type()'s per-character keys —
  180. // used for the avatar URL so a live `src`-bound preview does not fetch every half-typed prefix
  181. async function paste(page, selector, text) {
  182. await page.evaluate(`(() => { const el = document.querySelector(${J(selector)}); el.value = ${J(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  183. }
  184. let A, B, a, b;
  185. try {
  186. for (const [name, url] of [['main', IDENT + '/'], ['short', `http://127.0.0.1:${P_SHORT}/`]]) {
  187. let up = false;
  188. for (let i = 0; i < 80; i++) { try { const r = await fetch(url); if (r.ok) { up = true; break; } } catch {} await sleep(250); }
  189. if (!up) throw new Error(`${name} server did not come up\n` + procs.find(p => p.name === name).log());
  190. }
  191. // ---- the mission-003 app flow is gone; piece 2 rebuilt /login and /api/exchange ------------
  192. // (ticket #37: these two checks asserted 404s from before piece 2; they now check that the
  193. // mission-003 shapes are refused by the piece-2 routes. The full piece-2 rules: tests/apps.mjs)
  194. let r = await req('POST', IDENT + '/api/exchange', { raw: '{"code":"x"}', headers: { 'content-type': 'application/json' } });
  195. check('old exchange shape (code only): 400 naming the missing key, no identity', r.status === 400 && r.json && r.json.error === 'missing field: key' && r.json.identity === undefined, J(r));
  196. r = await req('GET', IDENT + '/login?app=testclient&return=http://127.0.0.1:8358/callback');
  197. check('old /login?app=… (no key): 400 error page, no redirect', r.status === 400 && r.location === null && /the key parameter/.test(r.text), `${r.status} ${r.location}`);
  198. r = await req('GET', IDENT + '/continue/abc');
  199. check('removed: GET /continue/<rid> is 404', r.status === 404, String(r.status));
  200. // ---- faces without a session ----------------------------------------------------------------
  201. let v = await face('addIdentity', [{ identityName: 'X' }]);
  202. check('face: addIdentity without a session → not signed in', v.error === 'you are not signed in', J(v));
  203. v = await face('changeTimeZone', ['Europe/Vienna']);
  204. check('face: changeTimeZone without a session → not signed in', v.error === 'you are not signed in', J(v));
  205. A = await launchBrowser({ debugPortRange: [CHROME[0], CHROME_MID] });
  206. B = await launchBrowser({ debugPortRange: [CHROME_MID + 1, CHROME[1]] });
  207. a = await A.newPage();
  208. b = await B.newPage();
  209. dialogs(a); dialogs(b);
  210. await a.send('Emulation.setTimezoneOverride', { timezoneId: TZ_A });
  211. await b.send('Emulation.setTimezoneOverride', { timezoneId: TZ_B });
  212. // ---- browser A: the first login creates the account --------------------------------------------
  213. await a.goto(IDENT + '/');
  214. await a.waitForSelector('#emailform');
  215. await connected(a, 'A connected');
  216. check('home: signed out shows the login (SSR), no identities', await a.evaluate('!!document.querySelector("#emailform") && !document.querySelector("#identities") && document.title === "ident | sign in"'), await a.evaluate('document.title'));
  217. check('home: the page says there is no sign-up', /no sign-up/.test(await a.text('ident-card')), await a.text('ident-card'));
  218. check('home: palette — accent orange #ce9178, danger #f44747, dark background', await a.evaluate(`(() => { const cs = getComputedStyle(document.documentElement); const s = document.createElement('span'); s.style.color = 'var(--color-danger)'; document.body.append(s); const d = getComputedStyle(s).color; s.remove(); return cs.getPropertyValue('--color-accent').trim() === '#ce9178' && getComputedStyle(document.querySelector('.brand')).color === 'rgb(206, 145, 120)' && getComputedStyle(document.querySelector('#sendcode')).backgroundColor === 'rgb(206, 145, 120)' && d === 'rgb(244, 71, 71)' && getComputedStyle(document.body).backgroundColor === 'rgb(25, 30, 35)'; })()`));
  219. await bothWidths(a, 'signin');
  220. await askCode(a, 'foo@bar');
  221. check('login: an address without a dotted domain is refused', (await msg(a)) === 'that is not an email address', await msg(a));
  222. await askCode(a, ' [email protected] ');
  223. check('login: code form shows the LOWERCASED address', (await a.text('#sentto strong')) === '[email protected]', await a.text('#sentto'));
  224. check('ident#20: "Send me a code" lands on the code page /code', (await step(a)) === '/code code:[email protected]', await step(a));
  225. const aliceCode1 = lastCode(SINK_MAIN, '[email protected]');
  226. check('mail: the sink got one 6-digit code for alice', mails(SINK_MAIN, '[email protected]').length === 1 && /^\d{6}$/.test(aliceCode1), readFileSync(SINK_MAIN, 'utf8'));
  227. await bothWidths(a, 'code');
  228. await enterCode(a, aliceCode1 === '000000' ? '111111' : '000000');
  229. await waitMsg(a, 'wrong code', 'wrong code message');
  230. check('login: a wrong code says so and counts down', (await msg(a)) === 'wrong code (4 tries left)', await msg(a));
  231. await enterCode(a, aliceCode1);
  232. await a.waitForSelector('#identityform');
  233. check('first login: the account exists at once — the default identity is listed', J(await listed(a)) === J([{ label: 'Default', details: 'no names', isDefault: true, edit: true, del: false }]), J(await listed(a)));
  234. check('first login: the name fields are shown and say they are OPTIONAL', /optional/i.test(await a.text('#welcome')) && (await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].every(s => !!document.querySelector(s) && !document.querySelector(s).required)')) && (await a.evaluate('!!document.querySelector("#skip")')), await a.text('#identityform'));
  235. check('first login: the fields are the default identity\'s (identity name "Default", names empty)', J(await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].map(s => document.querySelector(s).value)')) === J(['Default', '', '', '']));
  236. check('time zone: the browser\'s (emulated ' + TZ_A + ') was stored at first use', (await a.text('#timezone')) === TZ_A, await a.text('#timezone'));
  237. await bothWidths(a, 'welcome');
  238. // SKIPPED: the form closes, the default identity stays as it is
  239. await a.click('#skip');
  240. await a.waitFor('!document.querySelector("#identityform")', { label: 'welcome closed' });
  241. check('skip: the form closes, the default identity stays unnamed', J(await listed(a)) === J([{ label: 'Default', details: 'no names', isDefault: true, edit: true, del: false }]), J(await listed(a)));
  242. await a.goto(IDENT + '/');
  243. await a.waitForSelector('#identities');
  244. check('reload: still signed in (SSR), identities and time zone read back, no welcome again', (await a.text('#meemail')) === '[email protected]' && J(await labels(a)) === J(['Default']) && (await a.text('#timezone')) === TZ_A && !(await a.evaluate('!!document.querySelector("#identityform")')), await a.text('ident-card'));
  245. check('title: signed in', (await a.evaluate('document.title')) === 'ident | your identities', await a.evaluate('document.title'));
  246. check('cookie: ident uses its own cookie name (identsid), httpOnly', await a.cookies([IDENT]).then(cs => cs.some(c => c.name === 'identsid' && c.httpOnly) && !cs.some(c => c.name === 'hlsid')), J(await a.cookies([IDENT])));
  247. const aliceCookie = (await a.cookies([IDENT])).filter(c => c.name === 'identsid').map(c => `identsid=${c.value}`)[0];
  248. // EDIT the default identity: all four fields
  249. await connected(a, 'A before edit');
  250. await rowButton(a, 'Default', 'edit');
  251. await a.waitForSelector('#identityform');
  252. check('edit: the form is headed with the identity and has Cancel, not Skip', (await a.text('#formheading')) === 'Edit Default' && (await a.evaluate('!!document.querySelector("#cancel") && !document.querySelector("#skip") && !document.querySelector("#welcome")')), await a.text('#identityform'));
  253. await fill(a, { fidentityname: 'Private', fnickname: 'Ali', ffirstname: 'Alice', flastname: 'Example' });
  254. await upload(a, '#favatarfile', TXT_FILE);
  255. await a.waitFor(`document.querySelector('#avatarnote').textContent.length > 0`, { label: 'avatar: text file refused' });
  256. check('avatar: a text file is refused with a message, nothing is set', (await a.text('#avatarnote')).startsWith('That is not a picture') && (await a.evaluate(`document.querySelector('#favatar').value`)) === '', await a.text('#avatarnote'));
  257. await upload(a, '#favatarfile', PNG_FILE);
  258. await a.waitFor(`document.querySelector('#favatar').value.startsWith('data:image/')`, { label: 'avatar: picture scaled' });
  259. check('avatar: an uploaded picture is scaled to 128×128 and previewed before saving', await a.evaluate(`(async () => { const p = document.querySelector('#avatarpreview'); await new Promise(r => p.complete ? r() : (p.onload = r)); return p.naturalWidth === 128 && p.naturalHeight === 128 && p.src === document.querySelector('#favatar').value && !p.classList.contains('hidden') && document.querySelector('#favatar').value.length < 60000; })()`), await a.evaluate(`document.querySelector('#favatar').value.slice(0, 40)`));
  260. check('avatar: the centre-cropped, scaled picture keeps its colours (red left, blue right)', await a.evaluate(`(async () => { const p = document.querySelector('#avatarpreview'); const c = document.createElement('canvas'); c.width = c.height = 128; const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(p, 0, 0); const l = x.getImageData(10, 64, 1, 1).data, r = x.getImageData(118, 64, 1, 1).data; return l[0] > 200 && l[2] < 60 && r[2] > 200 && r[0] < 60; })()`));
  261. await bothWidths(a, 'edit');
  262. await a.click('#saveidentity');
  263. await waitNotice(a, 'Identity saved', 'saved notice');
  264. check('edit: saved — label and names shown', J(await listed(a)) === J([{ label: 'Private', details: '“Ali” · Alice Example', isDefault: true, edit: true, del: false }]), J(await listed(a)));
  265. check('avatar: the identity list shows a round avatar image with the saved picture', await a.evaluate(`(() => { const img = document.querySelector('#identities li img.avatar'); return !!img && img.src.startsWith('data:image/') && img.naturalWidth === 128; })()`), await a.evaluate(`document.querySelector('#identities').innerHTML`));
  266. // reopening the edit form shows the saved avatar again, and typing focus survives (ticket #15
  267. // regression: an `if` next to the text inputs in the SAME view made hl:webex recreate the
  268. // form on every keystroke, losing focus after one character — hybriel #32)
  269. await rowButton(a, 'Private', 'edit');
  270. await a.waitForSelector('#identityform');
  271. check('avatar: reopening edit shows the saved picture and preview', (await a.evaluate(`document.querySelector('#favatar').value`)).startsWith('data:image/') && await a.evaluate(`!document.querySelector('#avatarpreview').classList.contains('hidden')`));
  272. await a.focus('#fnickname');
  273. await a.send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'z', unmodifiedText: 'z', key: 'z' });
  274. await a.send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'z', unmodifiedText: 'z', key: 'z' });
  275. check('avatar: typing two characters in a row keeps focus (no per-keystroke re-render)', (await a.evaluate(`document.querySelector('#fnickname').value`)) === 'Alizz' && (await a.evaluate('document.activeElement && document.activeElement.id')) === 'fnickname');
  276. await a.click('#avatarremove');
  277. check('avatar: "Remove picture" hides the preview again', await a.evaluate(`document.querySelector('#avatarpreview').classList.contains('hidden')`));
  278. await a.click('#cancel');
  279. await a.waitFor('!document.querySelector("#identityform")', { label: 'avatar-check cancel closes' });
  280. // A SECOND identity, then a third with NO fields at all
  281. await a.click('#newidentity');
  282. await a.waitForSelector('#identityform');
  283. check('new: an empty form headed "New identity"', (await a.text('#formheading')) === 'New identity' && J(await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].map(s => document.querySelector(s).value)')) === J(['', '', '', '']));
  284. await fill(a, { fidentityname: 'Work', ffirstname: 'Alice', flastname: 'Example-Work' });
  285. await a.click('#saveidentity');
  286. await waitNotice(a, 'Identity created', 'created notice');
  287. check('new: the second identity is listed; now both can be deleted, the first stays default', J(await listed(a)) === J([
  288. { label: 'Private', details: '“Ali” · Alice Example', isDefault: true, edit: true, del: true },
  289. { label: 'Work', details: 'Alice Example-Work', isDefault: false, edit: true, del: true }]), J(await listed(a)));
  290. // hybriel#121 (mission 048): a face taking the session syncs the session-derived list back — a row added on top would show twice
  291. check('new: the created identity is listed ONCE (no session-sync double row)', (await labels(a)).filter(l => l === 'Work').length === 1 && (await a.evaluate('document.querySelectorAll("#identities li").length')) === 2, J(await labels(a)));
  292. await a.click('#newidentity');
  293. await a.waitForSelector('#identityform');
  294. await a.click('#saveidentity');
  295. await waitNotice(a, 'Identity created', 'created notice 3');
  296. check('new: an identity with no fields at all is fine (shown as "Identity 3")', J(await labels(a)) === J(['Private', 'Work', 'Identity 3']), J(await labels(a)));
  297. // cancel does not save
  298. await rowButton(a, 'Work', 'edit');
  299. await a.waitForSelector('#identityform');
  300. await fill(a, { fnickname: 'SHOULD-NOT-SAVE' });
  301. await a.click('#cancel');
  302. await a.waitFor('!document.querySelector("#identityform")', { label: 'cancel closes' });
  303. // edit the second one
  304. await rowButton(a, 'Work', 'edit');
  305. await a.waitForSelector('#identityform');
  306. check('edit: the form holds the stored values (the cancelled change is gone)', J(await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].map(s => document.querySelector(s).value)')) === J(['Work', '', 'Alice', 'Example-Work']), J(await a.evaluate('["#fidentityname","#fnickname","#ffirstname","#flastname"].map(s => document.querySelector(s).value)')));
  307. await fill(a, { fnickname: 'AE' });
  308. await a.click('#saveidentity');
  309. await waitNotice(a, 'Identity saved', 'saved notice 2');
  310. await a.goto(IDENT + '/');
  311. await a.waitForSelector('#identities');
  312. check('reload: three identities as saved', J(await listed(a)) === J([
  313. { label: 'Private', details: '“Ali” · Alice Example', isDefault: true, edit: true, del: true },
  314. { label: 'Work', details: '“AE” · Alice Example-Work', isDefault: false, edit: true, del: true },
  315. { label: 'Identity 3', details: 'no names', isDefault: false, edit: true, del: true }]), J(await listed(a)));
  316. await bothWidths(a, 'identities');
  317. // DELETE: a dismissed confirm keeps it; accepted removes it; the last one cannot go
  318. await connected(a, 'A before delete');
  319. a.dialogAnswer = false;
  320. await rowButton(a, 'Identity 3', 'delete');
  321. await sleep(500);
  322. check('delete: a dismissed confirm keeps the identity', a.dialogTexts.at(-1) === 'Delete the identity “Identity 3”?' && J(await labels(a)) === J(['Private', 'Work', 'Identity 3']), J(a.dialogTexts) + J(await labels(a)));
  323. a.dialogAnswer = true;
  324. await rowButton(a, 'Identity 3', 'delete');
  325. await waitNotice(a, 'Identity deleted', 'deleted notice');
  326. check('delete: accepted → gone', J(await labels(a)) === J(['Private', 'Work']), J(await labels(a)));
  327. await rowButton(a, 'Private', 'delete');
  328. await a.waitFor(`document.querySelectorAll('#identities li').length === 1`, { label: 'default deleted' });
  329. check('delete: the default one may go while another exists; the remaining one becomes default and has no Delete', J(await listed(a)) === J([{ label: 'Work', details: '“AE” · Alice Example-Work', isDefault: true, edit: true, del: false }]), J(await listed(a)));
  330. // ids are mpackdb UUIDs (mission 009): read Alice's remaining identity's id off its Edit button
  331. const aliceWorkId = await a.evaluate('document.querySelector("#identities li .edit").value');
  332. check('ids: an identity id is a 12-char mpackdb UUID (not a number)', /^[0-9a-z]{12}$/.test(aliceWorkId), J(aliceWorkId));
  333. v = await face('dropIdentity', [aliceWorkId], aliceCookie);
  334. check('face: deleting the LAST identity is refused', v.error === 'this is your only identity — an account always keeps one', J(v));
  335. await a.goto(IDENT + '/');
  336. await a.waitForSelector('#identities');
  337. check('reload: the last identity is still there', J(await labels(a)) === J(['Work']), J(await labels(a)));
  338. // TIME ZONE: change, browser button, unknown name refused in the page
  339. await connected(a, 'A before tz');
  340. await a.type('#tzinput', 'Mars/Olympus_Mons', { clear: true });
  341. await a.click('#savetz');
  342. await waitMsg(a, 'does not know', 'unknown zone');
  343. check('time zone: a name the browser does not know is refused, nothing saved', (await msg(a)) === 'this browser does not know the time zone “Mars/Olympus_Mons”' && (await a.text('#timezone')) === TZ_A, await msg(a));
  344. await a.type('#tzinput', 'Europe/Vienna', { clear: true });
  345. await a.click('#savetz');
  346. await waitNotice(a, 'Time zone saved', 'tz saved');
  347. check('time zone: changed to Europe/Vienna', (await a.text('#timezone')) === 'Europe/Vienna', await a.text('#timezone'));
  348. await a.click('#browsertz');
  349. await a.waitFor(`document.querySelector('#tzinput').value === ${J(TZ_A)}`, { label: 'browser tz button' });
  350. check('time zone: "Use this browser\'s" fills in the browser\'s zone (not saved yet)', (await a.text('#timezone')) === 'Europe/Vienna');
  351. await a.goto(IDENT + '/');
  352. await a.waitForSelector('#timezone');
  353. check('reload: the time zone reads back Europe/Vienna', (await a.text('#timezone')) === 'Europe/Vienna' && (await a.evaluate('document.querySelector("#tzinput").value')) === 'Europe/Vienna');
  354. // SIGN OUT, then a known address signs in: no welcome, the time zone is NOT reset
  355. await connected(a, 'A before sign out');
  356. await a.click('#signout');
  357. await a.waitForSelector('#emailform');
  358. await a.goto(IDENT + '/');
  359. await a.waitForSelector('#emailform');
  360. check('sign out: the reload is signed out', !(await a.evaluate('!!document.querySelector("#identities")')));
  361. v = await face('addIdentity', [{ identityName: 'after sign out' }], aliceCookie);
  362. check('face: the signed-out cookie cannot add an identity', v.error === 'you are not signed in', J(v));
  363. await askCode(a, '[email protected]');
  364. await enterCode(a, lastCode(SINK_MAIN, '[email protected]'));
  365. await a.waitForSelector('#identities');
  366. await sleep(300);
  367. check('login again: no welcome form, same identity, time zone kept (not the browser\'s)', !(await a.evaluate('!!document.querySelector("#identityform")')) && J(await labels(a)) === J(['Work']) && (await a.text('#timezone')) === 'Europe/Vienna', await a.text('ident-card'));
  368. await enterCode(a, lastCode(SINK_MAIN, '[email protected]')).catch(() => {});
  369. const aliceCookie2 = (await a.cookies([IDENT])).filter(c => c.name === 'identsid').map(c => `identsid=${c.value}`)[0];
  370. // ---- browser B: first login, names FILLED at the welcome; wrong codes; rate limit ----------------
  371. await b.goto(IDENT + '/');
  372. await askCode(b, '[email protected]');
  373. await enterCode(b, lastCode(SINK_MAIN, '[email protected]'));
  374. await b.waitForSelector('#identityform');
  375. check('B first login: time zone ' + TZ_B + ' from this browser', (await b.text('#timezone')) === TZ_B, await b.text('#timezone'));
  376. await fill(b, { fidentityname: 'Bob', fnickname: 'Bobby', ffirstname: 'Bob', flastname: 'Builder' });
  377. await b.click('#saveidentity');
  378. await waitNotice(b, 'Identity saved', 'B saved');
  379. check('B welcome FILLED: the default identity carries the names', J(await listed(b)) === J([{ label: 'Bob', details: '“Bobby” · Bob Builder', isDefault: true, edit: true, del: false }]), J(await listed(b)));
  380. const bobCookie = (await b.cookies([IDENT])).filter(c => c.name === 'identsid').map(c => `identsid=${c.value}`)[0];
  381. // FACE NEGATIVES (Bob's cookie): strict fields, other accounts' identities, time zone shapes
  382. const neg = [
  383. ['unknown field', ['addIdentity', [{ identityName: 'x', email: '[email protected]' }]], { error: 'unknown field: email', field: 'email' }],
  384. ['wrong type', ['addIdentity', [{ nickname: 5 }]], { error: 'field nickname must be a string', field: 'nickname' }],
  385. ['null value', ['addIdentity', [{ nickname: null }]], { error: 'field nickname must be a string', field: 'nickname' }],
  386. ['fields a list', ['addIdentity', [['x']]], { error: 'the identity fields must be an object', field: '' }],
  387. ['fields a string', ['addIdentity', ['x']], { error: 'the identity fields must be an object', field: '' }],
  388. ['61 characters', ['addIdentity', [{ lastname: 'x'.repeat(61) }]], { error: 'field lastname is longer than 60 characters', field: 'lastname' }],
  389. ['control character', ['addIdentity', [{ firstname: 'a\u0007b' }]], { error: 'field firstname contains a control character', field: 'firstname' }],
  390. ['avatar: a link is not a picture', ['addIdentity', [{ avatar: 'https://example.org/pic.png' }]], { error: 'field avatar must be an uploaded PNG, JPEG or WebP picture', field: 'avatar' }],
  391. ['avatar: svg refused', ['addIdentity', [{ avatar: 'data:image/svg+xml;base64,PHN2Zz4=' }]], { error: 'field avatar must be an uploaded PNG, JPEG or WebP picture', field: 'avatar' }],
  392. ['avatar: wrong bytes for the type', ['addIdentity', [{ avatar: 'data:image/png;base64,AAAAAAAAAAAA' }]], { error: 'field avatar is not a valid picture', field: 'avatar' }],
  393. ['avatar: not base64', ['addIdentity', [{ avatar: 'data:image/png;base64,iVBORw0KGgo<script>' }]], { error: 'field avatar is not valid base64', field: 'avatar' }],
  394. ['avatar: too long', ['addIdentity', [{ avatar: 'data:image/png;base64,iVBORw0KGgo' + 'A'.repeat(60000) }]], { error: 'field avatar is longer than 60000 characters', field: 'avatar' }],
  395. ['avatar: wrong type', ['addIdentity', [{ avatar: 5 }]], { error: 'field avatar must be a string', field: 'avatar' }],
  396. ['edit: another account\'s identity', ['editIdentity', [aliceWorkId, { nickname: 'hacked' }]], { error: 'no such identity', field: 'id' }],
  397. ['edit: id as a number (the old numeric ids)', ['editIdentity', [2, { nickname: 'x' }]], { error: 'no such identity', field: 'id' }],
  398. ['edit: unknown id', ['editIdentity', ['0zzzzzzzzzzz', {}]], { error: 'no such identity', field: 'id' }],
  399. ['delete: another account\'s identity', ['dropIdentity', [aliceWorkId]], { error: 'no such identity' }],
  400. ['delete: an old numeric id', ['dropIdentity', [2]], { error: 'no such identity' }],
  401. ['delete: own last identity', ['dropIdentity', ['(Bob\'s)']], { error: 'this is your only identity — an account always keeps one' }],
  402. ['time zone: bad characters', ['changeTimeZone', ['Europe/Vienna; x']], { error: 'that is not a time zone name (like Europe/Vienna)' }],
  403. ['time zone: a number', ['changeTimeZone', [5]], { error: 'the time zone must be a string' }],
  404. ['time zone: empty', ['changeTimeZone', [' ']], { error: 'the time zone is empty' }],
  405. ];
  406. const bobIds = await face('addIdentity', [{}], bobCookie); // a 2nd identity: learn Bob's ids
  407. const bobRows = bobIds.identities || [];
  408. check('face: Bob can add an identity with an empty object', bobRows.length === 2, J(bobIds));
  409. await face('dropIdentity', [bobRows[1] && bobRows[1].id], bobCookie);
  410. for (const [label, [ev, payload], want] of neg) {
  411. // `own last identity` must name Bob's id
  412. const p = label === 'delete: own last identity' ? [bobRows[0].id] : payload;
  413. v = await face(ev, p, bobCookie);
  414. check(`face negative: ${label}`, J(v) === J(want), J(v));
  415. }
  416. // IMPERSONATION: a hand-made frame with one argument too many puts ITS object where the
  417. // session goes (hybriel: positional trailing session); ident only takes a real session
  418. v = await face('addIdentity', [{ identityName: 'forged' }, { user: { id: 1 }, data: {} }]);
  419. check('face: a forged session object (extra argument, no cookie) is refused', v.refused === true || v.error === 'you are not signed in', J(v));
  420. v = await face('dropIdentity', [aliceWorkId, { user: { id: 1 }, data: {} }], bobCookie);
  421. check('face: a forged session with Bob\'s cookie cannot delete Alice\'s identity', v.refused === true || v.error === 'you are not signed in' || v.error === 'no such identity', J(v));
  422. await a.goto(IDENT + '/');
  423. await a.waitForSelector('#identities');
  424. check('Alice\'s identities are untouched by all of the above', J(await listed(a)) === J([{ label: 'Work', details: '“AE” · Alice Example-Work', isDefault: true, edit: true, del: false }]), J(await listed(a)));
  425. v = await face('changeTimeZone', ['UTC'], aliceCookie2);
  426. check('face: UTC is a valid zone', v.timeZone === 'UTC', J(v));
  427. // OTP RULES (kept from mission 003): 5 wrong codes, rate limit
  428. await b.click('#signout');
  429. await b.waitForSelector('#emailform');
  430. await askCode(b, '[email protected]');
  431. const carolCode = lastCode(SINK_MAIN, '[email protected]');
  432. const wrong = carolCode === '123456' ? '654321' : '123456';
  433. const seen = [];
  434. for (let i = 0; i < 5; i++) {
  435. await enterCode(b, wrong);
  436. await b.waitFor('/\\S/.test(document.querySelector("#message").textContent)', { label: 'wrong try ' + (i + 1) });
  437. seen.push(await msg(b));
  438. }
  439. check('otp: five wrong codes count down and then kill the code', J(seen) === J(['wrong code (4 tries left)', 'wrong code (3 tries left)', 'wrong code (2 tries left)', 'wrong code (1 tries left)', 'too many wrong codes — ask for a new one']), J(seen));
  440. await enterCode(b, carolCode);
  441. await b.waitFor('/\\S/.test(document.querySelector("#message").textContent)', { label: 'right code after kill' });
  442. check('otp: after 5 wrong tries even the RIGHT code is refused, and no account exists', (await msg(b)) === 'no code is waiting for this address — ask for a new one' && !(await b.evaluate('!!document.querySelector("#identities")')), await msg(b));
  443. await b.click('#back');
  444. await askCode(b, '[email protected]');
  445. const carolOld = lastCode(SINK_MAIN, '[email protected]');
  446. await b.click('#back');
  447. await askCode(b, '[email protected]');
  448. check('rate limit: the 3rd code in 10 minutes is still sent', mails(SINK_MAIN, '[email protected]').length === 3, J(mails(SINK_MAIN, '[email protected]')));
  449. await enterCode(b, carolOld === lastCode(SINK_MAIN, '[email protected]') ? '000001' : carolOld);
  450. await waitMsg(b, 'wrong code', 'old code refused');
  451. check('otp: a replaced (older) code no longer works', /^wrong code/.test(await msg(b)), await msg(b));
  452. await b.click('#back');
  453. await askCode(b, '[email protected]');
  454. check('rate limit: the 4th is refused and not mailed', /too many codes were sent/.test(await msg(b)) && mails(SINK_MAIN, '[email protected]').length === 3, await msg(b));
  455. v = await face('verifyCode', ['[email protected]', lastCode(SINK_MAIN, '[email protected]'), 'UTC', { user: { id: 1 }, data: {} }]);
  456. check('face: verifyCode with a forged trailing session is refused (no sign-in, code not spent)', v.refused === true || v.error === 'no session — reload the page', J(v));
  457. v = await face('verifyCode', ['[email protected]', 123456, 'UTC']);
  458. check('face: verifyCode with a numeric code → refused', v.error === 'email and code must be strings', J(v));
  459. // single use: the code that signed Bob in does not work twice (fresh session, REST)
  460. const bobFirst = mails(SINK_MAIN, '[email protected]')[0];
  461. const fr = await fetch(IDENT + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ t: 'emit', i: 901, event: 'signOut', payload: [] }) });
  462. const freshCookie = fr.headers.get('set-cookie').split(';')[0];
  463. v = await face('verifyCode', ['[email protected]', bobFirst, 'UTC'], freshCookie);
  464. check('otp: a code that was used once is refused the second time', v.error === 'no code is waiting for this address — ask for a new one', J(v));
  465. // ---- ident#20 (mission 032): THE CODE PAGE /code SURVIVES A RELOAD --------------------------
  466. // "Send me a code" records the address in the session (face rememberPending) and goes to
  467. // /code; /code shows the code form while that code is waiting, else 302 back to the form.
  468. await b.goto(IDENT + '/');
  469. await connected(b, 'B before ident#20');
  470. const bCookie = await cookieOf(b, IDENT);
  471. check('ident#20: after "Other address" the browser is on the email form', (await step(b)) === '/ email', await step(b));
  472. check('ident#20: GET /code with nothing pending → 302 to /', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));
  473. check('ident#20: GET /code without any cookie → 302 to /', (await ssr('/code')) === '302 /', await ssr('/code'));
  474. await askCode(b, '[email protected]');
  475. check('ident#20: "Send me a code" → /code shows the code form for dave', (await step(b)) === '/code code:[email protected]', await step(b));
  476. check('ident#20: the server renders /code for this session (SSR, plain GET: 200 + code form + address)', (await ssr('/code', bCookie)) === '200 code:[email protected]', await ssr('/code', bCookie));
  477. await reload(b);
  478. check('ident#20: RELOAD of /code → still the code form, code field there, address named, no email form', (await step(b)) === '/code code:[email protected]' && await b.evaluate('!!document.querySelector("#code") && !document.querySelector("#email") && document.title === "ident | sign in"'), await step(b));
  479. await bothWidths(b, 'code-reloaded');
  480. const b2 = await B.newPage();
  481. await b2.goto(IDENT + '/code');
  482. await b2.waitForSelector('ident-card');
  483. check('ident#20: a SECOND TAB of the same browser opens /code with the code form too', (await step(b2)) === '/code code:[email protected]', await step(b2));
  484. await b2.close();
  485. const daveCode = lastCode(SINK_MAIN, '[email protected]');
  486. await enterCode(b, daveCode === '000000' ? '111111' : '000000');
  487. await waitMsg(b, 'wrong code', 'dave wrong code');
  488. await reload(b);
  489. check('ident#20: a WRONG code keeps it (reload → code form)', (await step(b)) === '/code code:[email protected]', await step(b));
  490. check('ident#20: one mail for dave — the reloads re-sent nothing', mails(SINK_MAIN, '[email protected]').length === 1, J(mails(SINK_MAIN, '[email protected]')));
  491. await enterCode(b, daveCode);
  492. await b.waitForSelector('#identityform');
  493. check('ident#20: the code entered after the reloads signs in (first login: welcome form), address bar back on /', (await b.text('#meemail')) === '[email protected]' && /optional/i.test(await b.text('#welcome')) && (await b.evaluate('location.pathname')) === '/', (await b.evaluate('location.pathname')) + ' ' + await b.text('ident-card'));
  494. await b.click('#skip');
  495. await b.waitFor('!document.querySelector("#identityform")', { label: 'dave skip' });
  496. await reload(b);
  497. check('ident#20: signed in → reload shows the account', (await step(b)) === '/ signedin' && (await b.text('#meemail')) === '[email protected]', await step(b));
  498. check('ident#20: signed in → GET /code is 302 to / (the sign-in cleared it)', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));
  499. await b.click('#signout');
  500. await b.waitForSelector('#emailform');
  501. await b.goto(IDENT + '/code');
  502. await b.waitForSelector('#emailform');
  503. check('ident#20: signed out again → /code in the browser lands on the email form at /', (await step(b)) === '/ email', await step(b));
  504. // "Other address" forgets it
  505. await connected(b, 'B before frank');
  506. await askCode(b, '[email protected]');
  507. check('ident#20: frank on /code', (await step(b)) === '/code code:[email protected]', await step(b));
  508. await b.click('#back');
  509. await b.waitFor('location.pathname === "/" && !!document.querySelector("#emailform")', { label: 'back to /' });
  510. check('ident#20: "Other address" → the email form at /', (await step(b)) === '/ email', await step(b));
  511. check('ident#20: "Other address" → the server forgot it (GET /code is 302 to /)', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));
  512. await reload(b);
  513. check('ident#20: "Other address" → RELOAD shows the email form', (await step(b)) === '/ email', await step(b));
  514. // a code killed by 5 wrong tries is no longer waiting → /code sends the browser back
  515. await askCode(b, '[email protected]');
  516. const ginaWrong = lastCode(SINK_MAIN, '[email protected]') === '123456' ? '654321' : '123456';
  517. for (let i = 0; i < 5; i++) {
  518. await enterCode(b, ginaWrong);
  519. await b.waitFor('/\\S/.test(document.querySelector("#message").textContent)', { label: 'gina wrong ' + (i + 1) });
  520. }
  521. check('ident#20: five wrong codes kill gina\'s code', (await msg(b)) === 'too many wrong codes — ask for a new one', await msg(b));
  522. await reload(b);
  523. check('ident#20: a killed code → reload of /code lands on the email form at /', (await step(b)) === '/ email', await step(b));
  524. // THE FACE is honest: it records only an address a code is really waiting for
  525. v = await face('rememberPending', ['[email protected]'], bCookie);
  526. check('ident#20 face: rememberPending without a waiting code → refused', v.error === 'no code is waiting for this address', J(v));
  527. check('ident#20 face: … and /code still sends this browser back (302 /)', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));
  528. v = await face('rememberPending', [5], bCookie);
  529. check('ident#20 face: rememberPending with a number → refused', v.error === 'field email must be a string', J(v));
  530. const hq = await req('POST', IDENT + '/api/code', { raw: J({ email: '[email protected]' }), headers: { 'content-type': 'application/json' } });
  531. v = await face('rememberPending', ['[email protected]', { user: null, data: {} }], bCookie);
  532. check('ident#20 face: a forged trailing session is refused', hq.status === 200 && (v.refused === true || v.error === 'no session — reload the page'), J(v));
  533. check('ident#20 face: … and nothing was recorded (302 /)', (await ssr('/code', bCookie)) === '302 /', await ssr('/code', bCookie));
  534. v = await face('rememberPending', [' [email protected] '], bCookie);
  535. check('ident#20 face: an address with a waiting code is accepted, normalised', v.email === '[email protected]', J(v));
  536. check('ident#20: … /code now shows it for THIS session only (other cookie / none → 302)', (await ssr('/code', bCookie)) === '200 code:[email protected]' && (await ssr('/code')) === '302 /' && (await ssr('/code', aliceCookie2)) === '302 /', [await ssr('/code', bCookie), await ssr('/code'), await ssr('/code', aliceCookie2)].join(' | '));
  537. v = await face('forgetPending', [], bCookie);
  538. check('ident#20 face: forgetPending → /code is 302 to / again', v === true && (await ssr('/code', bCookie)) === '302 /', J(v) + ' ' + await ssr('/code', bCookie));
  539. check('ident#20: /signin/<not a request id>/code (xyz, a CR/LF try) → 302 to / (the rid never reaches the Location header)', (await ssr('/signin/%0d%0aX:y/code', bCookie)) === '302 /' && (await ssr('/signin/xyz/code', bCookie)) === '302 /', await ssr('/signin/%0d%0aX:y/code', bCookie));
  540. check('ident#20: /signin/<rid>/code with nothing pending → 302 to /signin/<rid>', (await ssr('/signin/0123456789abcdef0123456789abcdef/code', bCookie)) === '302 /signin/0123456789abcdef0123456789abcdef', await ssr('/signin/0123456789abcdef0123456789abcdef/code', bCookie));
  541. // ---- expiry (the short-clock server) ---------------------------------------------------------
  542. await a.goto(SHORT + '/');
  543. await askCode(a, '[email protected]');
  544. const erinCode = lastCode(SINK_SHORT, '[email protected]');
  545. await reload(a);
  546. check('ident#20: short server — reload right after asking → the code form', (await step(a)) === '/code code:[email protected]', await step(a));
  547. await sleep(3500);
  548. await enterCode(a, erinCode);
  549. await waitMsg(a, 'expired', 'expired otp');
  550. check('otp: an expired code (TTL 3 s on this server) is refused', (await msg(a)) === 'the code expired — ask for a new one', await msg(a));
  551. await reload(a);
  552. check('ident#20: an EXPIRED code → reload of /code lands on the email form at /', (await step(a)) === '/ email', await step(a));
  553. await askCode(a, '[email protected]');
  554. check('ident#20: short server — ivy on /code', (await step(a)) === '/code code:[email protected]', await step(a));
  555. await sleep(3500);
  556. await reload(a);
  557. check('ident#20: expiry alone (no try) → reload of /code lands on the email form', (await step(a)) === '/ email', await step(a));
  558. // mission 010: the code request is a fetch to /api/code; its refusals (invalid address 400,
  559. // rate limit 429) make Chrome log "Failed to load resource … 400/429" — expected, shown on the page
  560. const expected = /favicon|status of (400|429) .*\/api\/code$/;
  561. const problems = [...a.problems(), ...b.problems()].filter(m => !expected.test(m.text));
  562. check('browsers: no console errors or warnings', problems.length === 0, J(problems.slice(0, 5)));
  563. // ---- mission 046: THE INSTALLABLE APP (manifest, icons, service worker) and the offline shell ------------
  564. const head = await (await fetch(IDENT + '/')).text();
  565. const link = (rel) => { const m = new RegExp(`<link rel="${rel}" href="([^"]+)"`).exec(head); return m ? m[1] : null; };
  566. const mhref = link('manifest');
  567. check('pwa: the head links the manifest, the apple-touch-icon, the favicon and the theme colour (the header colour, token darker)', mhref === '/__hl/manifest.webmanifest' && link('apple-touch-icon') === '/icons/apple-touch-icon.png' && link('icon') === '/favicon.ico' && /<meta name="theme-color" content="rgb\(15, 20, 25\)">/.test(head), J([mhref, link('apple-touch-icon'), link('icon')]));
  568. const mres = await fetch(IDENT + (mhref || '/__hl/manifest.webmanifest'));
  569. const man = await mres.json();
  570. check('pwa: manifest served as manifest JSON: name ident, start_url /start (the data-free start page), standalone, theme = header (darker), background dark', /manifest\+json/.test(mres.headers.get('content-type') || '') && man.name === 'ident' && man.start_url === '/start' && man.display === 'standalone' && man.theme_color === 'rgb(15, 20, 25)' && man.background_color === 'rgb(25, 30, 35)', J(man));
  571. const pngOk = async (src, size) => { const r = await fetch(IDENT + src); const bb = Buffer.from(await r.arrayBuffer()); return r.ok && bb.readUInt32BE(0) === 0x89504e47 && bb.readUInt32BE(16) === size && bb.readUInt32BE(20) === size; };
  572. const iconOks = [];
  573. for (const ic of man.icons) iconOks.push(ic.type === 'image/png' && await pngOk(ic.src, Number(ic.sizes.split('x')[0])));
  574. check('pwa: every manifest icon is a real PNG of its declared size (192 + 512, any + maskable)', iconOks.length === 4 && iconOks.every(Boolean) && ['any', 'maskable'].every(pu => ['192x192', '512x512'].every(sz => man.icons.some(i => i.purpose === pu && i.sizes === sz))), J(man.icons));
  575. const fav = await fetch(IDENT + '/favicon.ico'); const favB = Buffer.from(await fav.arrayBuffer());
  576. check('pwa: apple-touch-icon is a 180 px PNG, /favicon.ico a real ICO', (await pngOk('/icons/apple-touch-icon.png', 180)) && fav.ok && favB.readUInt32BE(0) === 0x00000100, String(fav.status));
  577. r = await req('GET', IDENT + '/api/online');
  578. check('pwa: /api/online (the shell\'s network probe) answers 204, not cached', r.status === 204 && r.text === '', String(r.status));
  579. // THE OFFLINE COPY HOLDS NO PERSONAL DATA (ident is the identity provider; shared devices): browser A
  580. // is signed in; with the network gone neither `/` nor `/start`, nor anything in the worker's caches
  581. // or hl:web's IndexedDB, may contain the account's address or an identity name
  582. const w = await A.newPage();
  583. await viewport(w, 390, 844);
  584. await w.goto(IDENT + '/');
  585. await w.waitForSelector('#meemail');
  586. await connected(w, 'pwa page');
  587. const myEmail = (await w.text('#meemail')).trim();
  588. // the identity as it would leak: its details line (names) — a bare label like "Work" is also a word in the framework's code
  589. const myIdentity = await w.evaluate('(document.querySelector("#identities identity-details") || {}).textContent || ""');
  590. check('pwa: browser A is signed in (the data that must not be kept offline)', /@example\.org$/.test(myEmail) && myIdentity !== '', myEmail + ' ' + myIdentity);
  591. await w.waitFor(`navigator.serviceWorker.getRegistration().then(r => !!(r && r.active))`, { label: 'service worker active', timeout: 15000 });
  592. check('pwa: a service worker is registered for the whole app (scope /) and controls the page', await w.evaluate(`navigator.serviceWorker.getRegistration().then(r => !!r && new URL(r.scope).pathname === '/' && !!navigator.serviceWorker.controller)`));
  593. const inst = await w.send('Page.getInstallabilityErrors');
  594. check('pwa: Chrome reports no installability error', (inst.installabilityErrors || []).length === 0, J(inst));
  595. await sleep(1500); // the probe's first ask (0.5 s after the page is up)
  596. check('pwa: online, the header says nothing about offline', await w.evaluate('!document.querySelector("#offline") && !!document.querySelector("application-header")'));
  597. // the installed app starts at /start: online it goes on to / by itself (the shell's probe)
  598. await w.goto(IDENT + '/start');
  599. await w.waitFor('location.pathname === "/" && !!document.querySelector("#meemail")', { label: '/start → /', timeout: 8000 }).catch(() => {});
  600. check('pwa: online, /start (the start_url) goes on to / (signed in)', await w.evaluate('location.pathname === "/" && !!document.querySelector("#meemail")'), await w.evaluate('location.pathname'));
  601. await connected(w, 'pwa / again');
  602. // everything this browser keeps offline, as text: every response in the worker's caches + hl:web's IndexedDB
  603. const keptText = () => w.evaluate(`(async () => {
  604. let out = '';
  605. for (const name of await caches.keys()) { const c = await caches.open(name); for (const rq of await c.keys()) { const r = await c.match(rq); out += '\\n' + rq.url + '\\n' + await r.clone().text(); } }
  606. const db = await new Promise((res) => { const q = indexedDB.open('hl-offline'); q.onsuccess = () => res(q.result); q.onerror = () => res(null); });
  607. if (db) { for (const st of db.objectStoreNames) { const all = await new Promise((res) => { const q = db.transaction(st).objectStore(st).getAll(); q.onsuccess = () => res(q.result); q.onerror = () => res([]); }); out += '\\n' + JSON.stringify(all); } db.close(); }
  608. return out;
  609. })()`);
  610. // NO NETWORK: the tab AND the service worker (a separate CDP target — the tab's emulation alone
  611. // leaves the worker's fetches online, measured) are cut off
  612. const swSessions = {};
  613. const swOffline = async (on) => {
  614. const { targetInfos } = await A.conn.send('Target.getTargets');
  615. const sws = targetInfos.filter(t => t.type === 'service_worker' && t.url.startsWith(IDENT));
  616. for (const t of sws) {
  617. const sid = swSessions[t.targetId] ||= (await A.conn.send('Target.attachToTarget', { targetId: t.targetId, flatten: true })).sessionId;
  618. await A.conn.send('Network.enable', {}, sid);
  619. await A.conn.send('Network.emulateNetworkConditions', { offline: on, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }, sid);
  620. }
  621. return sws.length;
  622. };
  623. const nSw = await swOffline(true);
  624. await w.setOffline(true);
  625. const cutOff = await w.evaluate(`Promise.resolve(window.__hlAttempt(() => fetch('/api/online', { cache: 'no-store' }))).then(r => !r.ok)`);
  626. check('pwa: offline set up (the worker and the tab cannot reach ident)', nSw === 1 && cutOff, `${nSw} ${cutOff}`);
  627. // a real reload of the signed-in `/`: it is not kept, so the worker answers its data-free "Unavailable offline"
  628. await w.send('Page.reload');
  629. await w.waitFor('!!document.querySelector("main[data-hl-offline]") || !!document.querySelector("application-header")', { label: 'offline reload of /', timeout: 15000 }).catch(() => {});
  630. let doc = await w.evaluate('document.documentElement.outerHTML');
  631. check('pwa: OFFLINE reload of the signed-in / → the worker\'s "Unavailable offline" page, NO email or identity in it', /Unavailable offline/.test(doc) && !doc.includes(myEmail) && !doc.includes(myIdentity), doc.slice(0, 300));
  632. // the installed app's start: the data-free shell from the cache, and it says it is offline
  633. await w.goto(IDENT + '/start').catch(() => {});
  634. await w.waitFor('!!document.querySelector("application-header") && !!document.querySelector("#start")', { label: 'offline /start', timeout: 15000 }).catch(() => {});
  635. await w.waitFor('!!document.querySelector("#offline")', { label: 'offline note', timeout: 8000 }).catch(() => {});
  636. check('pwa: OFFLINE /start (the start_url) shows the shell (header, nav, start card) from the worker', await w.evaluate('location.pathname === "/start" && !!document.querySelector("application-header nav #navapps") && !!document.querySelector("#start")'), await w.evaluate('document.body.innerText.slice(0, 200)'));
  637. check('pwa: … and the header says it is offline', (await w.evaluate('(document.querySelector("#offline") || {}).textContent || ""')) === 'Offline — ident needs the network to sign in and to save', await w.evaluate('document.body.innerText.slice(0, 200)'));
  638. doc = await w.evaluate('document.documentElement.outerHTML');
  639. check('pwa: … and it contains NO email or identity of the signed-in user', !doc.includes(myEmail) && !doc.includes(myIdentity) && !/@example\.org/.test(doc));
  640. const kept = await keptText();
  641. check('pwa: NOTHING the browser keeps offline (worker caches + hl:web IndexedDB) holds the email or an identity', kept.length > 1000 && !kept.includes(myEmail) && !kept.includes(myIdentity) && !/@example\.org/.test(kept), kept.length + ' chars; hits: ' + [myEmail, myIdentity, '@example.org'].map(x => kept.indexOf(x)).join(' ') + ' ' + J(myIdentity));
  642. await shot(w, 'phone-pwa-offline');
  643. check('layout: 390px offline shell has no horizontal overflow', await noOverflow(w));
  644. await w.goto(IDENT + '/apps').catch(() => {});
  645. await w.waitFor('!!document.querySelector("main[data-hl-offline]")', { label: 'offline /apps', timeout: 8000 }).catch(() => {});
  646. check('pwa: offline, a data page (/apps) needs the network: the worker\'s "Unavailable offline" page', await w.evaluate('location.pathname === "/apps" && /Unavailable offline/.test(document.body.textContent)'), await w.evaluate('document.body.innerText.slice(0, 200)'));
  647. // back online, the start page opens ident by itself
  648. await w.goto(IDENT + '/start').catch(() => {});
  649. await w.waitForSelector('#start');
  650. await swOffline(false);
  651. await w.setOffline(false);
  652. await w.waitFor('location.pathname === "/" && !!document.querySelector("#meemail")', { label: 'back online → /', timeout: 30000 }).catch(() => {});
  653. await connected(w, 'pwa back online').catch(() => {});
  654. await sleep(1000);
  655. check('pwa: back online, the offline start page goes on to / by itself (signed in, no offline note)', await w.evaluate('location.pathname === "/" && !!document.querySelector("#meemail") && !document.querySelector("#offline")'), await w.evaluate('location.pathname + " " + document.body.innerText.slice(0, 120)'));
  656. await shot(w, 'phone-pwa-online');
  657. await w.close();
  658. } catch (e) {
  659. failures++;
  660. console.log('FAIL (aborted) ' + (e && e.stack || e));
  661. for (const [n, pg] of [['A', a], ['B', b]]) if (pg) console.log(`console ${n}: ` + J(pg.messages.slice(-8)));
  662. } finally {
  663. for (const br of [A, B]) { if (br) { try { await br.close(); } catch {} } }
  664. for (const { p } of procs) { try { p.kill('SIGTERM'); } catch {} }
  665. await sleep(500);
  666. for (const { p } of procs) { if (p.exitCode === null && p.signalCode === null) { try { p.kill('SIGKILL'); } catch {} } }
  667. await sleep(200);
  668. console.log(`\n${passes} passed, ${failures} failed`);
  669. process.exit(failures ? 1 : 0);
  670. }

Branches

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