gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitf8bdcbc2f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmref8bdcbc2/tests/notify.mjs

36.7 KB

  1. // tests/notify.mjs — THE GATE OF PIECE 4 (ticket ident#6, mission 013): notification kinds,
  2. // the send API, the inbox, the per-app page. Real headless Chrome (tests/cdp.mjs,
  3. // --disable-gpu) for what the user does; this script plays the APP'S SERVER (key + secret)
  4. // for what an app does:
  5. // an app registers kinds → sends to identities (normal + urgent, with/without icon and
  6. // link, a registered and an unregistered name) → the user sees them in /inbox (all
  7. // identities, newest first), marks read/unread → opens the per-app page → flips switches
  8. // and the override → the STORED effective settings change (read off a COPY of the gate's
  9. // store with tools/dump-store.hl) → a later notification gets the new channels.
  10. // Negatives: wrong secret / unknown key, another app's identity id, unknown identity,
  11. // strict bodies (unknown/missing/wrong-type fields, bad URLs, control characters, invalid
  12. // JSON, GET), forged face sessions (#31), another account can neither see nor change them.
  13. //
  14. // Own servers only, NEVER the dev server and NEVER real mail:
  15. // ident :8410 (IDENT_MAIL_SINK), a node server :8413 (the app's icon + link target).
  16. // Own storage: .scratch/notify-gate/ (wiped at start). Chrome debug ports 8740-8749.
  17. // Screenshots: .scratch/notify-{inbox,appsettings,override}-{390,1280}.png — look at them.
  18. //
  19. // Run: node tests/notify.mjs (exit 0 = all passed)
  20. import { spawn, execFileSync } from 'node:child_process';
  21. import { createServer } from 'node:http';
  22. import { deflateSync } from 'node:zlib';
  23. import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, cpSync } from 'node:fs';
  24. import { dirname, join } from 'node:path';
  25. import { fileURLToPath } from 'node:url';
  26. import { launchBrowser, sleep } from './cdp.mjs';
  27. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  28. const APP = join(dirname(fileURLToPath(import.meta.url)), '..');
  29. const G = join(APP, '.scratch/notify-gate');
  30. const SHOTS = join(APP, '.scratch');
  31. const ID = 'http://127.0.0.1:8410';
  32. const SITE = 'http://127.0.0.1:8413'; // "the app": its icon and its action link
  33. const CHROME_PORTS = [8740, 8749];
  34. const J = JSON.stringify;
  35. let passed = 0, failed = 0;
  36. const check = (name, ok, detail = '') => {
  37. if (ok) { passed++; console.log(' ok ' + name); }
  38. else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
  39. };
  40. // ---- servers ----------------------------------------------------------------------------
  41. const procs = [];
  42. function start(cwd, env, log) {
  43. const p = spawn(join(APP, 'bin/hybriel'), ['project.hl'], {
  44. cwd, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env },
  45. stdio: ['ignore', 'pipe', 'pipe'],
  46. });
  47. let out = '';
  48. p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { out += d; });
  49. p.on('exit', () => writeFileSync(join(G, log), out));
  50. procs.push({ p, log });
  51. return p;
  52. }
  53. async function up(url) {
  54. for (let i = 0; i < 80; i++) { try { await fetch(url + '/', { redirect: 'manual' }); return; } catch {} await sleep(250); }
  55. throw new Error('server did not come up: ' + url);
  56. }
  57. rmSync(G, { recursive: true, force: true });
  58. mkdirSync(join(G, 'main'), { recursive: true });
  59. const STORE = join(G, 'main', 'ident');
  60. start(APP, {
  61. IDENT_PORT: '8410', IDENT_STORAGE: STORE, IDENT_SESSIONS: join(G, 'main', 'sess') + '/',
  62. IDENT_MAIL_SINK: join(G, 'main', 'mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000',
  63. }, 'ident.log');
  64. // the app's site: a 64x64 PNG icon (one blue square, built here) and a page for the link
  65. const png = (() => {
  66. // a valid PNG built with zlib: 64x64 RGB, one colour
  67. const w = 64, h = 64, raw = Buffer.alloc((w * 3 + 1) * h);
  68. for (let y = 0; y < h; y++) { raw[y * (w * 3 + 1)] = 0; for (let x = 0; x < w; x++) { const o = y * (w * 3 + 1) + 1 + x * 3; raw[o] = 0x56; raw[o + 1] = 0x9b; raw[o + 2] = 0xd4; } }
  69. 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; }
  70. const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcT[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
  71. const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([len, td, c]); };
  72. const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
  73. return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
  74. })();
  75. const site = createServer((req, res) => {
  76. if (req.url.startsWith('/icon.png')) { res.writeHead(200, { 'content-type': 'image/png' }); res.end(png); return; }
  77. res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  78. res.end('<!doctype html><html><head><meta charset="utf-8"><title>the app</title></head><body><p id="landed">the app: ' + req.url.replace(/[<>&]/g, '') + '</p></body></html>');
  79. });
  80. await new Promise(r => site.listen(8413, '127.0.0.1', r));
  81. await up(ID);
  82. // ---- helpers ----------------------------------------------------------------------------
  83. const lastCode = (email) => {
  84. const lines = readFileSync(join(G, 'main', 'mail.txt'), 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' '));
  85. return lines.length ? lines[lines.length - 1].split(' ')[1] : null;
  86. };
  87. let emitI = 0;
  88. // hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is
  89. // ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.
  90. 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; } };
  91. async function emit(event, payload, cookie) {
  92. const r = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });
  93. const t = await r.text();
  94. let j = null; try { j = JSON.parse(t); } catch {}
  95. return { status: r.status, cookie: (r.headers.get('set-cookie') || '').split(';')[0], value: j && j.value, raw: t };
  96. }
  97. async function api(path, body, method = 'POST') {
  98. const r = await fetch(ID + path, { method, headers: { 'content-type': 'application/json' }, ...(method === 'GET' ? {} : { body: typeof body === 'string' ? body : J(body) }) });
  99. const t = await r.text();
  100. let j = null; try { j = JSON.parse(t); } catch {}
  101. return { status: r.status, j, t };
  102. }
  103. const idsOf = {};
  104. async function apiLogin(email) {
  105. const q = await fetch(ID + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email }) });
  106. if (q.status !== 200) throw new Error('code request failed: ' + q.status);
  107. const v = await emit('verifyCode', [email, lastCode(email), 'UTC']);
  108. if (!v.value || !v.value.account || !v.cookie) throw new Error('api login failed: ' + v.raw);
  109. idsOf[v.cookie] = v.value.identities.map(i => i.id);
  110. return v.cookie;
  111. }
  112. // THE APP'S SIDE of a login: button → choice → exchange → the app-specific identity id
  113. async function appIdentity(cookie, app, identity) {
  114. const l = await fetch(ID + '/login?key=' + app.key + '&return=' + encodeURIComponent(SITE + '/callback'), { redirect: 'manual' });
  115. const rid = (l.headers.get('location') || '').split('/').pop();
  116. const c = await emit('chooseIdentity', [rid, identity], cookie);
  117. const code = new URL(c.value.url).searchParams.get('ident_code');
  118. const x = await api('/api/exchange', { key: app.key, secret: app.secret, code });
  119. if (x.status !== 200) throw new Error('exchange failed: ' + x.t);
  120. return x.j.identity;
  121. }
  122. // THE STORED STATE, read off a COPY (opening a table rewrites it, hybriel #40)
  123. let copyN = 0;
  124. function store() {
  125. const copy = join(G, 'copy-' + (++copyN));
  126. cpSync(STORE, copy, { recursive: true });
  127. const out = execFileSync(join(APP, 'bin/hybriel'), ['tools/dump-store.hl'], { cwd: APP, env: { ...process.env, IDENT_STORAGE: copy }, encoding: 'utf8' });
  128. const line = out.split('\n').find(l => l.startsWith('{'));
  129. return JSON.parse(line);
  130. }
  131. const setting = (s, conn, kind) => s.settings.find(r => r.pair === conn + ':' + kind);
  132. const pages = [];
  133. async function ready(p) { await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'hydrated' }); }
  134. async function viewport(p, w) {
  135. await p.send('Emulation.setDeviceMetricsOverride', { width: w, height: w < 500 ? 844 : 900, deviceScaleFactor: 1, mobile: w < 500 });
  136. }
  137. async function shot(p, name) {
  138. for (const w of [390, 1280]) {
  139. await viewport(p, w);
  140. await sleep(200);
  141. const over = await p.evaluate('document.documentElement.scrollWidth > window.innerWidth');
  142. check(`${name} @${w}px: no horizontal overflow`, !over);
  143. const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  144. writeFileSync(join(SHOTS, `notify-${name}-${w}.png`), Buffer.from(data, 'base64'));
  145. }
  146. await viewport(p, 1280);
  147. }
  148. const txt = (p, sel) => p.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || ''`);
  149. const attr = (p, sel, a) => p.evaluate(`(document.querySelector(${J(sel)}) || { getAttribute: () => null }).getAttribute(${J(a)})`);
  150. const count = (p, sel) => p.evaluate(`document.querySelectorAll(${J(sel)}).length`);
  151. async function browserLogin(p, email) {
  152. await p.goto(ID + '/');
  153. await p.waitForSelector('#email');
  154. await ready(p);
  155. await p.type('#email', email);
  156. await p.click('#sendcode');
  157. await p.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'code page' });
  158. await ready(p); // ident#20: the code step is its own page (/code) — wait for it to hydrate
  159. await p.type('#code', lastCode(email));
  160. await p.click('#verify');
  161. }
  162. let browser1, browser2;
  163. try {
  164. browser1 = await launchBrowser({ debugPortRange: CHROME_PORTS });
  165. browser2 = await launchBrowser({ debugPortRange: CHROME_PORTS });
  166. const p = await browser1.newPage(); pages.push(p);
  167. await viewport(p, 1280);
  168. // ==== 0. accounts, apps, per-app ids ==================================================
  169. console.log('# setup: alice (2 identities), bob, apps A and B, per-app ids');
  170. const ALICE = '[email protected]', BOB = '[email protected]';
  171. await p.goto(ID + '/inbox');
  172. check('signed out /inbox asks to sign in', /sign in/i.test(await txt(p, '#signinfirst')));
  173. await browserLogin(p, ALICE);
  174. await p.waitForSelector('#skip');
  175. await p.click('#skip');
  176. await p.waitFor('!document.querySelector("#identityform")');
  177. const alice = await apiLogin(ALICE); // a second session of the same account (for API calls)
  178. await emit('addIdentity', [{ identityName: 'Work' }], alice);
  179. const aliceIds = (await emit('editIdentity', [idsOf[alice][0], { identityName: 'Default' }], alice)).value.identities.map(i => i.id);
  180. check('alice has two identities', aliceIds.length === 2, J(aliceIds));
  181. const mkApp = async (name) => { const v = (await emit('appCreate', [{ name, origins: [SITE] }], alice)).value; return { key: v.app.apiKey, secret: v.secret }; };
  182. const A = await mkApp('Chat app'), B = await mkApp('Shop');
  183. const idA1 = await appIdentity(alice, A, aliceIds[0]); // alice Default in A
  184. const idA2 = await appIdentity(alice, A, aliceIds[1]); // alice Work in A
  185. const idB1 = await appIdentity(alice, B, aliceIds[0]); // alice Default in B
  186. const bob = await apiLogin(BOB);
  187. const idBob = await appIdentity(bob, A, idsOf[bob][0]); // bob in A
  188. check('short ids: the same for alice Default in A and B, three different ones in all', idA1 === idB1 && new Set([idA1, idA2, idB1, idBob]).size === 3 && [idA1, idA2, idB1, idBob].every(x => /^[2-9a-hj-km-np-z]{5}$/.test(x)));
  189. let s = store();
  190. const identOf = (short) => s.identities.find(i => i.shortId === short);
  191. const connOf = (app, short) => s.connections.find(c => c.identity === identOf(short).id && c.app === app);
  192. const cA1 = connOf(s.apps.find(x => x.apiKey === A.key).id, idA1), cA2 = connOf(s.apps.find(x => x.apiKey === A.key).id, idA2), cB1 = connOf(s.apps.find(x => x.apiKey === B.key).id, idB1), cBob = connOf(s.apps.find(x => x.apiKey === A.key).id, idBob);
  193. // ==== 1. the app registers its kinds ==================================================
  194. console.log('# kinds');
  195. let r = await api('/api/kinds', { key: A.key, secret: A.secret, kinds: [{ name: 'New comment', push: true, email: false }, { name: 'Weekly digest', push: false, email: true }] });
  196. check('register kinds → 200, the registered list', r.status === 200 && J(r.j) === J({ kinds: [{ email: false, name: 'New comment', push: true }, { email: true, name: 'Weekly digest', push: false }] }), r.t);
  197. r = await api('/api/kinds', { key: A.key, secret: A.secret, kinds: [] });
  198. check('empty list → 200, lists the kinds', r.status === 200 && r.j.kinds.length === 2, r.t);
  199. const kindsBad = [
  200. [{ key: A.key, secret: B.secret, kinds: [] }, 401, /wrong secret/, 'wrong secret'],
  201. [{ key: 'pk_nope', secret: A.secret, kinds: [] }, 401, /wrong secret/, 'unknown key'],
  202. [{ key: A.key, secret: A.secret }, 400, /missing field: kinds/, 'missing kinds'],
  203. [{ key: A.key, secret: A.secret, kinds: {} }, 400, /must be a list/, 'kinds not a list'],
  204. [{ key: A.key, secret: A.secret, kinds: [], extra: 1 }, 400, /unknown field: extra/, 'unknown top field'],
  205. [{ key: A.key, secret: A.secret, kinds: [{ name: 'X', push: true, email: true, sound: 1 }] }, 400, /unknown field: kinds\[0\]\.sound/, 'unknown kind field'],
  206. [{ key: A.key, secret: A.secret, kinds: [{ name: 'X', push: true }] }, 400, /missing field: kinds\[0\]\.email/, 'missing kind field'],
  207. [{ key: A.key, secret: A.secret, kinds: [{ name: 'X', push: 'yes', email: true }] }, 400, /push must be a boolean/, 'push not boolean'],
  208. [{ key: A.key, secret: A.secret, kinds: [{ name: '', push: true, email: true }] }, 400, /empty/, 'empty name'],
  209. [{ key: A.key, secret: A.secret, kinds: [{ name: 'x'.repeat(61), push: true, email: true }] }, 400, /longer than 60/, 'name too long'],
  210. [{ key: A.key, secret: A.secret, kinds: [{ name: 'Y', push: true, email: true }, { name: 'Y', push: false, email: true }] }, 400, /twice/, 'duplicate name'],
  211. [{ key: A.key, secret: A.secret, kinds: ['X'] }, 400, /must be an object/, 'kind not an object'],
  212. ];
  213. for (const [body, st, re, label] of kindsBad) {
  214. r = await api('/api/kinds', body);
  215. check(`kinds refused: ${label} → ${st}`, r.status === st && re.test(r.j && r.j.error), r.t);
  216. }
  217. r = await api('/api/kinds', { key: A.key, secret: A.secret, kinds: [{ name: 'Ok', push: true, email: true }, { name: 'Bad', push: 1, email: true }] });
  218. s = store();
  219. check('a refused call writes nothing (no kind "Ok")', r.status === 400 && !s.kinds.some(k => k.name === 'Ok'), r.t);
  220. r = await api('/api/kinds', '{"key":');
  221. check('kinds: invalid JSON → 400', r.status === 400 && /not valid JSON/.test(r.j.error), r.t);
  222. r = await api('/api/kinds', null, 'GET');
  223. check('kinds: GET → 405', r.status === 405, r.t);
  224. // ==== 2. the app sends ================================================================
  225. console.log('# send');
  226. const send = (body) => api('/api/notify', { key: A.key, secret: A.secret, ...body });
  227. const n1 = await send({ identity: idA1, name: 'New comment', text: 'Bea commented on your post:\n“Nice one!”', icon: SITE + '/icon.png', link: SITE + '/post/7' });
  228. check('normal notification with icon + link → 200 { id }', n1.status === 200 && /^[0-9a-z]{12}$/.test(n1.j.id) && Object.keys(n1.j).length === 1, n1.t);
  229. await sleep(20);
  230. const n2 = await send({ identity: idA2, name: 'Weekly digest', text: 'Your week in the chat app.', urgent: true });
  231. check('urgent notification without icon/link → 200', n2.status === 200, n2.t);
  232. await sleep(20);
  233. const n3 = await send({ identity: idA1, name: 'Mention', text: 'You were mentioned.', urgent: false });
  234. check('unregistered name → allowed (200)', n3.status === 200, n3.t);
  235. await sleep(20);
  236. const n4 = await api('/api/notify', { key: B.key, secret: B.secret, identity: idB1, name: 'Invoice', text: 'Invoice #12 is ready.', link: SITE + '/invoice/12' });
  237. check('app B sends to its own id → 200', n4.status === 200, n4.t);
  238. await sleep(20);
  239. const nBob = await send({ identity: idBob, name: 'New comment', text: 'BOB-ONLY secret text' });
  240. check('app A sends to bob → 200', nBob.status === 200, nBob.t);
  241. const sendBad = [
  242. [{ key: A.key, secret: B.secret, identity: idA1, name: 'x', text: 'y' }, 401, /wrong secret/, 'wrong secret'],
  243. [{ key: 'pk_x', secret: A.secret, identity: idA1, name: 'x', text: 'y' }, 401, /wrong secret/, 'unknown key'],
  244. [{ key: B.key, secret: B.secret, identity: idA2, name: 'x', text: 'y' }, 404, /unknown identity/, "an identity that never logged in to this app"],
  245. [{ key: A.key, secret: A.secret, identity: '0'.repeat(32), name: 'x', text: 'y' }, 404, /unknown identity/, 'unknown identity id'],
  246. [{ key: A.key, secret: A.secret, identity: aliceIds[0], name: 'x', text: 'y' }, 404, /unknown identity/, "ident's own identity id"],
  247. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', email: '[email protected]' }, 400, /unknown field: email/, 'unknown field'],
  248. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x' }, 400, /missing field: text/, 'missing text'],
  249. [{ key: A.key, secret: A.secret, identity: idA1, text: 'y' }, 400, /missing field: name/, 'missing name'],
  250. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', urgent: 'yes' }, 400, /urgent must be a boolean/, 'urgent not boolean'],
  251. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 5 }, 400, /text must be a string/, 'text not a string'],
  252. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: ' ' }, 400, /text is empty/, 'empty text'],
  253. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y'.repeat(2001) }, 400, /longer than 2000/, 'text too long'],
  254. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'a\u0007b' }, 400, /control character/, 'control char in text'],
  255. [{ key: A.key, secret: A.secret, identity: idA1, name: 'a\nb', text: 'y' }, 400, /control character/, 'newline in name'],
  256. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', icon: 'javascript:alert(1)' }, 400, /http/, 'javascript: icon'],
  257. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', link: 'ftp://a.b/' }, 400, /http/, 'ftp link'],
  258. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', link: 'https://a.b/x y' }, 400, /not allowed/, 'space in link'],
  259. [{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', link: 'https:///x' }, 400, /no host/, 'link without host'],
  260. ];
  261. for (const [body, st, re, label] of sendBad) {
  262. r = await api('/api/notify', body);
  263. check(`send refused: ${label} → ${st}`, r.status === st && re.test(r.j && r.j.error), r.t);
  264. }
  265. r = await api('/api/notify', '{"key":"a","secret":"b","identity":"c","name":"d","text":"\\ud83d\\ude00"}');
  266. check('send: surrogate escape → 400 (not 500)', r.status === 400, r.t);
  267. r = await api('/api/notify', '[1]');
  268. check('send: not an object → 400', r.status === 400 && /object/.test(r.j.error), r.t);
  269. r = await api('/api/notify', null, 'GET');
  270. check('send: GET → 405', r.status === 405, r.t);
  271. // what was stored: channels per notification, nothing delivered
  272. s = store();
  273. const note = (id) => s.notifications.find(n => n.id === id);
  274. check('stored: 5 notifications (refusals stored nothing)', s.notifications.length === 5, s.notifications.length);
  275. let x = note(n1.j.id);
  276. check('New comment (preset push on, email off): push, no mail', x && x.push === true && x.mail === 'none' && x.urgent === false, J(x));
  277. check('stored fields: identity, app, connection, icon, link, text, unread, not delivered', x && x.identity === aliceIds[0] && x.connection === cA1.id && x.icon === SITE + '/icon.png' && x.link === SITE + '/post/7' && x.text.includes('\n') && x.read === false && x.pushSent === false && x.mailSent === false, J(x));
  278. x = note(n2.j.id);
  279. check('urgent Weekly digest (email on, no push device): mail now', x && x.urgent === true && x.push === false && x.mail === 'now', J(x));
  280. x = note(n3.j.id);
  281. check('unregistered Mention: daily mail, no push', x && x.push === false && x.mail === 'daily', J(x));
  282. const mention = s.kinds.find(k => k.name === 'Mention');
  283. check('unregistered name stored as an unregistered kind (email on, push off)', mention && mention.registered === false && mention.email === true && mention.push === false, J(mention));
  284. check('effective settings stored for the connection', setting(s, cA1.id, 'New comment') && setting(s, cA1.id, 'New comment').push === true && setting(s, cA1.id, 'New comment').email === false && setting(s, cA1.id, 'Mention').push === false && setting(s, cA1.id, 'Mention').email === true, J(s.settings));
  285. // ==== 3. the inbox (browser) ==========================================================
  286. console.log('# inbox');
  287. await p.goto(ID + '/inbox');
  288. await p.waitForSelector('#inbox'); await ready(p);
  289. const names = await p.evaluate('[...document.querySelectorAll("#inbox li note-name")].map(e => e.textContent)');
  290. check('inbox: alice sees her 4 (both identities, both apps), newest first', J(names) === J(['Invoice', 'Mention', 'Weekly digest', 'New comment']), J(names));
  291. check('inbox: bob\'s notification is not there', !(await p.evaluate('document.body.textContent.includes("BOB-ONLY")')));
  292. check('inbox: 4 unread', (await txt(p, '#unreadcount')) === '4 unread', await txt(p, '#unreadcount'));
  293. const row = (n) => `#inbox li:nth-child(${n})`;
  294. check('inbox: app + identity shown', (await txt(p, row(1) + ' note-from')) === 'Shop · Default' && (await txt(p, row(3) + ' note-from')) === 'Chat app · Work', (await txt(p, row(1) + ' note-from')) + ' | ' + (await txt(p, row(3) + ' note-from')));
  295. check('inbox: text with its line break', (await p.evaluate(`document.querySelector("${row(4)} note-text").innerText`)).includes('post:\n“Nice one!”'));
  296. check('inbox: icon shown (loaded, no referrer)', (await attr(p, row(4) + ' img.icon', 'src')) === SITE + '/icon.png' && (await attr(p, row(4) + ' img.icon', 'referrerpolicy')) === 'no-referrer' && (await p.evaluate(`document.querySelector("${row(4)} img.icon").naturalWidth`)) === 64);
  297. check('inbox: no icon / no link where none was sent', (await count(p, row(3) + ' img')) === 0 && (await count(p, row(3) + ' a.action')) === 0);
  298. check('inbox: action link', (await attr(p, row(4) + ' a.action', 'href')) === SITE + '/post/7' && (await attr(p, row(4) + ' a.action', 'rel')) === 'noopener noreferrer');
  299. check('inbox: urgent badge only on the urgent one', (await count(p, row(3) + ' urgent-badge')) === 1 && (await count(p, '#inbox urgent-badge')) === 1);
  300. check('inbox: time shown', /^\d{4}-\d\d-\d\d \d\d:\d\d UTC$/.test(await txt(p, row(4) + ' time')), await txt(p, row(4) + ' time'));
  301. check('inbox: all unread', (await count(p, '#inbox li.unread')) === 4);
  302. await shot(p, 'inbox');
  303. // mark read / unread
  304. await p.click(row(4) + ' .toggle');
  305. await p.waitFor(`document.querySelector("${row(4)}").className === "read"`, { label: 'marked read' });
  306. check('mark read: row read, 3 unread, button says Mark unread', (await txt(p, '#unreadcount')) === '3 unread' && (await txt(p, row(4) + ' .toggle')) === 'Mark unread');
  307. await p.goto(ID + '/inbox'); await ready(p);
  308. check('read state survives a reload', (await attr(p, row(4), 'class')) === 'read' && (await txt(p, '#unreadcount')) === '3 unread');
  309. s = store();
  310. check('stored: read = true, readAt set', note(n1.j.id).read === true && note(n1.j.id).readAt > 0);
  311. await p.click(row(3) + ' .toggle');
  312. await p.waitFor(`document.querySelector("${row(3)}").className === "read"`);
  313. await p.click(row(3) + ' .toggle');
  314. await p.waitFor(`document.querySelector("${row(3)}").className === "unread"`, { label: 'marked unread' });
  315. check('mark unread again', (await txt(p, '#unreadcount')) === '3 unread');
  316. await shot(p, 'inbox-read');
  317. // the action link opens the app
  318. const href = await attr(p, row(4) + ' a.action', 'href');
  319. check('action link target answers (the app)', (await (await fetch(href)).text()).includes('/post/7'));
  320. // ==== 4. the per-app page ===============================================================
  321. console.log('# per-app page');
  322. const conns = await p.evaluate('[...document.querySelectorAll("#conns li")].map(li => li.textContent)');
  323. check('inbox lists the 3 app connections (identity · since)', conns.length === 3 && conns.some(c => c.startsWith('Chat appWork · since')) && conns.some(c => c.startsWith('ShopDefault · since')), J(conns));
  324. await p.click(`#conns a[href="/inbox/${cA1.id}"]`);
  325. await p.waitFor(`location.pathname === "/inbox/${cA1.id}" && !!document.querySelector("#kinds")`, { label: 'per-app page' });
  326. await ready(p);
  327. const since = new Date(cA1.created).toISOString();
  328. check('per-app page: app, identity, registered since (= the connection\'s created)', (await txt(p, '#connapp')) === 'Chat app' && (await txt(p, '#connidentity')) === 'Default' && (await txt(p, '#connsince')) === since.slice(0, 10) + ' ' + since.slice(11, 16) + ' UTC', (await txt(p, '#connsince')) + ' vs ' + since);
  329. const kindNames = await p.evaluate('[...document.querySelectorAll("#kinds kind-name")].map(e => e.textContent)');
  330. check('per-app page: all the app\'s notifications by name', J(kindNames) === J(['Mention', 'New comment', 'Weekly digest']), J(kindNames));
  331. const k = (n, ch) => `#kinds li:nth-child(${n}) .switch.${ch}`;
  332. const state = async () => ({
  333. ov: [await attr(p, '#ovactive', 'aria-checked'), await attr(p, '#ovpush', 'aria-checked'), await attr(p, '#ovemail', 'aria-checked')],
  334. kinds: await p.evaluate('[...document.querySelectorAll("#kinds li")].map(li => [li.querySelector(".switch.push") ? li.querySelector(".switch.push").getAttribute("aria-checked") : "none", li.querySelector(".switch.email").getAttribute("aria-checked")])'),
  335. });
  336. let st = await state();
  337. check('switches show the presets (Mention: no push)', J(st.kinds) === J([['none', 'true'], ['true', 'false'], ['false', 'true']]), J(st));
  338. check('override off, its switches: push off, email on', J(st.ov) === J(['false', 'false', 'true']), J(st.ov));
  339. check('Mention says it is not registered', /not registered/.test(await txt(p, '#kinds li:nth-child(1) kind-preset')));
  340. await shot(p, 'appsettings');
  341. // flip: New comment email on, Weekly digest push on
  342. await p.click(k(2, 'email'));
  343. await p.waitFor(`document.querySelector(${J(k(2, 'email'))}).getAttribute("aria-checked") === "true"`, { label: 'email switched on' });
  344. await p.click(k(3, 'push'));
  345. await p.waitFor(`document.querySelector(${J(k(3, 'push'))}).getAttribute("aria-checked") === "true"`, { label: 'push switched on' });
  346. check('flip shows "Saved."', (await txt(p, '#notice')) === 'Saved.');
  347. s = store();
  348. let nc = setting(s, cA1.id, 'New comment'), wd = setting(s, cA1.id, 'Weekly digest');
  349. check('stored: New comment user email on → effective push on + email on', nc.userEmail === 'on' && nc.push === true && nc.email === true, J(nc));
  350. check('stored: Weekly digest user push on → effective push on + email on', wd.userPush === 'on' && wd.push === true && wd.email === true, J(wd));
  351. check('other identity\'s settings untouched (Work in the same app)', setting(s, cA2.id, 'New comment').email === false, J(setting(s, cA2.id, 'New comment')));
  352. await p.goto(ID + '/inbox/' + cA1.id); await ready(p);
  353. st = await state();
  354. check('switches survive a reload', J(st.kinds) === J([['none', 'true'], ['true', 'true'], ['true', 'true']]), J(st));
  355. // a notification now follows the new effective settings
  356. const n5 = await send({ identity: idA1, name: 'New comment', text: 'Another comment.' });
  357. s = store();
  358. check('new notification follows the user: push + daily mail', note(n5.j.id).push === true && note(n5.j.id).mail === 'daily', J(note(n5.j.id)));
  359. // THE OVERRIDE
  360. await p.click('#ovactive');
  361. await p.waitFor('document.querySelector("#ovactive").getAttribute("aria-checked") === "true"', { label: 'override on' });
  362. st = await state();
  363. check('override on: every kind = push off, email on', J(st.kinds) === J([['none', 'true'], ['false', 'true'], ['false', 'true']]), J(st));
  364. check('override on: per-kind switches locked', (await count(p, '#kinds .switch.locked')) === 5, await count(p, '#kinds .switch.locked'));
  365. await p.click('#ovemail');
  366. await p.waitFor('document.querySelector("#ovemail").getAttribute("aria-checked") === "false"', { label: 'override email off' });
  367. await p.click('#ovpush');
  368. await p.waitFor('document.querySelector("#ovpush").getAttribute("aria-checked") === "true"', { label: 'override push on' });
  369. st = await state();
  370. check('override push on / email off: all kinds follow, Mention still no push', J(st.kinds) === J([['none', 'false'], ['true', 'false'], ['true', 'false']]), J(st));
  371. s = store();
  372. const ovr = setting(s, cA1.id, '');
  373. check('stored: the override row', ovr && ovr.active === true && ovr.push === true && ovr.email === false, J(ovr));
  374. check('stored effective: all kinds email off, push on where registered', ['New comment', 'Weekly digest'].every(n => setting(s, cA1.id, n).push === true && setting(s, cA1.id, n).email === false) && setting(s, cA1.id, 'Mention').push === false && setting(s, cA1.id, 'Mention').email === false, J(s.settings.filter(r => r.connection === cA1.id)));
  375. check('stored: the user\'s own switches kept under the override', setting(s, cA1.id, 'New comment').userEmail === 'on');
  376. await shot(p, 'override');
  377. const n6 = await send({ identity: idA1, name: 'Mention', text: 'Mentioned again.', urgent: true });
  378. const n7 = await send({ identity: idA1, name: 'Weekly digest', text: 'Digest.' });
  379. s = store();
  380. check('under the override: urgent Mention (push not allowed, email off) → inbox only', note(n6.j.id).push === false && note(n6.j.id).mail === 'none', J(note(n6.j.id)));
  381. check('under the override: Weekly digest → push, no mail', note(n7.j.id).push === true && note(n7.j.id).mail === 'none', J(note(n7.j.id)));
  382. // clicking a locked switch changes nothing
  383. await p.click(k(2, 'email'));
  384. await sleep(300);
  385. check('a locked switch does nothing', (await attr(p, k(2, 'email'), 'aria-checked')) === 'false');
  386. await p.click('#ovactive');
  387. await p.waitFor('document.querySelector("#ovactive").getAttribute("aria-checked") === "false"', { label: 'override off' });
  388. st = await state();
  389. check('override off: the user\'s own switches are back', J(st.kinds) === J([['none', 'true'], ['true', 'true'], ['true', 'true']]), J(st));
  390. s = store();
  391. check('stored effective back to the user\'s switches', setting(s, cA1.id, 'New comment').email === true && setting(s, cA1.id, 'Mention').email === true);
  392. // the app changes its presets / registers the unregistered name
  393. r = await api('/api/kinds', { key: A.key, secret: A.secret, kinds: [{ name: 'Mention', push: true, email: false }, { name: 'New comment', push: false, email: false }] });
  394. check('app re-registers: Mention now registered', r.status === 200 && r.j.kinds.length === 3, r.t);
  395. s = store();
  396. check('stored effective follows the new presets where the user did not switch', setting(s, cA1.id, 'Mention').push === true && setting(s, cA1.id, 'Mention').email === false && setting(s, cA2.id, 'New comment').push === false, J([setting(s, cA1.id, 'Mention'), setting(s, cA2.id, 'New comment')]));
  397. check('…and the user\'s switches still win', setting(s, cA1.id, 'New comment').email === true, J(setting(s, cA1.id, 'New comment')));
  398. await p.goto(ID + '/inbox/' + cA1.id); await ready(p);
  399. check('Mention now has a push switch', (await count(p, '#kinds li:nth-child(1) .switch.push')) === 1 && (await attr(p, k(1, 'push'), 'aria-checked')) === 'true');
  400. // ==== 5. faces: forged sessions, strict, other accounts ===============================
  401. console.log('# faces');
  402. const forged = { user: { id: s.accounts.find(a => a.email === ALICE).id } };
  403. for (const [ev, args] of [['inboxMark', [n1.j.id, true]], ['inboxSwitch', [cA1.id, 'New comment', 'email', false]], ['inboxOverride', [cA1.id, 'active', true]]]) {
  404. const f = await emit(ev, [...args, forged]);
  405. check(`forged session argument refused: ${ev}`, framework_refused(f.raw) || (f.value && /not signed in/.test(f.value.error)), f.raw);
  406. const f2 = await emit(ev, args);
  407. check(`no session refused: ${ev}`, f2.value && /not signed in/.test(f2.value.error), f2.raw);
  408. }
  409. let e = await emit('inboxMark', [n1.j.id, false], bob);
  410. check("bob cannot mark alice's notification", e.value.error === 'no such notification', e.raw);
  411. e = await emit('inboxSwitch', [cA1.id, 'New comment', 'email', false], bob);
  412. check("bob cannot switch alice's settings", e.value.error === 'no such app connection', e.raw);
  413. e = await emit('inboxOverride', [cA1.id, 'active', true], bob);
  414. check("bob cannot set alice's override", e.value.error === 'no such app connection', e.raw);
  415. const bobPage = await (await fetch(ID + '/inbox/' + cA1.id, { headers: { cookie: bob } })).text();
  416. check("bob opening alice's per-app page: not found, nothing of hers", bobPage.includes('does not exist or is not yours') && !bobPage.includes('Weekly digest'));
  417. const bobInbox = await (await fetch(ID + '/inbox', { headers: { cookie: bob } })).text();
  418. check("bob's inbox: only his own", bobInbox.includes('BOB-ONLY') && !bobInbox.includes('Invoice') && !bobInbox.includes('Mention'));
  419. s = store();
  420. check("alice's data unchanged by bob", note(n1.j.id).read === true && setting(s, cA1.id, 'New comment').email === true && !setting(s, cA1.id, '').active);
  421. e = await emit('inboxMark', [n1.j.id, 'yes'], alice);
  422. check('inboxMark: read not a boolean → refused', /must be a boolean/.test(e.value.error), e.raw);
  423. e = await emit('inboxMark', [5, true], alice);
  424. check('inboxMark: id not a string → refused', e.value.error === 'no such notification', e.raw);
  425. e = await emit('inboxSwitch', [cA1.id, 'New comment', 'sms', true], alice);
  426. check('inboxSwitch: unknown channel refused', /push or email/.test(e.value.error), e.raw);
  427. e = await emit('inboxSwitch', [cA1.id, 'New comment', 'email', 'on'], alice);
  428. check('inboxSwitch: value not a boolean refused', /must be a boolean/.test(e.value.error), e.raw);
  429. e = await emit('inboxSwitch', [cA1.id, 'Nope', 'email', true], alice);
  430. check('inboxSwitch: unknown kind refused', /no such notification kind/.test(e.value.error), e.raw);
  431. e = await emit('inboxSwitch', [cB1.id, 'Invoice', 'push', true], alice);
  432. check('inboxSwitch: push on an unregistered kind refused', /cannot be pushed/.test(e.value.error), e.raw);
  433. e = await emit('inboxSwitch', [7, 'Invoice', 'email', true], alice);
  434. check('inboxSwitch: connection not a string refused', /must be a string/.test(e.value.error), e.raw);
  435. e = await emit('inboxOverride', [cA1.id, 'sound', true], alice);
  436. check('inboxOverride: unknown field refused', /active, push or email/.test(e.value.error), e.raw);
  437. e = await emit('inboxSwitch', [cBob.id, 'New comment', 'email', true], alice);
  438. check("alice cannot switch bob's connection", e.value.error === 'no such app connection', e.raw);
  439. const unknownPage = await (await fetch(ID + '/inbox/zzzzzzzzzzzz', { headers: { cookie: alice } })).text();
  440. check('unknown connection id → not found page', unknownPage.includes('does not exist or is not yours'));
  441. const signedOutPage = await (await fetch(ID + '/inbox/' + cA1.id)).text();
  442. check('signed out per-app page → sign in', signedOutPage.includes('Sign in to ident first') && !signedOutPage.includes('Weekly digest'));
  443. // ==== 6. a second browser: bob sees only his own ======================================
  444. console.log('# second browser (bob)');
  445. const q = await browser2.newPage(); pages.push(q);
  446. await viewport(q, 390);
  447. await browserLogin(q, BOB);
  448. await q.waitFor('!!document.querySelector("#identities")');
  449. await q.goto(ID + '/inbox'); await q.waitForSelector('#inbox'); await ready(q);
  450. check('bob (browser): one notification, his', (await count(q, '#inbox li')) === 1 && (await txt(q, '#inbox li note-text')) === 'BOB-ONLY secret text');
  451. await q.goto(ID + '/inbox/' + cA1.id);
  452. check("bob (browser): alice's per-app page refused", /not yours/.test(await txt(q, '#notfound')));
  453. // ==== 7. console ======================================================================
  454. const probs = pages.flatMap(x => x.problems().map(m => m.text));
  455. check('no console errors or warnings', probs.length === 0, probs.join(' | '));
  456. } catch (err) {
  457. failed++;
  458. console.log(' FAIL (aborted) ' + (err && err.stack || err));
  459. for (const x of pages) console.log('--- console:\n' + x.dumpConsole());
  460. } finally {
  461. for (const b of [browser1, browser2]) { if (b) { try { await b.close(); } catch {} } }
  462. for (const { p } of procs) { try { p.kill(); } catch {} }
  463. site.close();
  464. await sleep(300);
  465. }
  466. console.log(`\n${passed} passed, ${failed} failed`);
  467. process.exit(failed ? 1 : 0);

Branches

Latest commits

  • 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