ident
All repositories: gitoria
36.7 KB
// tests/notify.mjs — THE GATE OF PIECE 4 (ticket ident#6, mission 013): notification kinds,// the send API, the inbox, the per-app page. Real headless Chrome (tests/cdp.mjs,// --disable-gpu) for what the user does; this script plays the APP'S SERVER (key + secret)// for what an app does:// an app registers kinds → sends to identities (normal + urgent, with/without icon and// link, a registered and an unregistered name) → the user sees them in /inbox (all// identities, newest first), marks read/unread → opens the per-app page → flips switches// and the override → the STORED effective settings change (read off a COPY of the gate's// store with tools/dump-store.hl) → a later notification gets the new channels.// Negatives: wrong secret / unknown key, another app's identity id, unknown identity,// strict bodies (unknown/missing/wrong-type fields, bad URLs, control characters, invalid// JSON, GET), forged face sessions (#31), another account can neither see nor change them.//// Own servers only, NEVER the dev server and NEVER real mail:// ident :8410 (IDENT_MAIL_SINK), a node server :8413 (the app's icon + link target).// Own storage: .scratch/notify-gate/ (wiped at start). Chrome debug ports 8740-8749.// Screenshots: .scratch/notify-{inbox,appsettings,override}-{390,1280}.png — look at them.//// Run: node tests/notify.mjs (exit 0 = all passed)import { spawn, execFileSync } from 'node:child_process';import { createServer } from 'node:http';import { deflateSync } from 'node:zlib';import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync, cpSync } from 'node:fs';import { dirname, join } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser, sleep } from './cdp.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const APP = join(dirname(fileURLToPath(import.meta.url)), '..');const G = join(APP, '.scratch/notify-gate');const SHOTS = join(APP, '.scratch');const ID = 'http://127.0.0.1:8410';const SITE = 'http://127.0.0.1:8413'; // "the app": its icon and its action linkconst CHROME_PORTS = [8740, 8749];const J = JSON.stringify;let passed = 0, failed = 0;const check = (name, ok, detail = '') => {if (ok) { passed++; console.log(' ok ' + name); }else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }};// ---- servers ----------------------------------------------------------------------------const procs = [];function start(cwd, env, log) {const p = spawn(join(APP, 'bin/hybriel'), ['project.hl'], {cwd, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env },stdio: ['ignore', 'pipe', 'pipe'],});let out = '';p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { out += d; });p.on('exit', () => writeFileSync(join(G, log), out));procs.push({ p, log });return p;}async function up(url) {for (let i = 0; i < 80; i++) { try { await fetch(url + '/', { redirect: 'manual' }); return; } catch {} await sleep(250); }throw new Error('server did not come up: ' + url);}rmSync(G, { recursive: true, force: true });mkdirSync(join(G, 'main'), { recursive: true });const STORE = join(G, 'main', 'ident');start(APP, {IDENT_PORT: '8410', IDENT_STORAGE: STORE, IDENT_SESSIONS: join(G, 'main', 'sess') + '/',IDENT_MAIL_SINK: join(G, 'main', 'mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000',}, 'ident.log');// the app's site: a 64x64 PNG icon (one blue square, built here) and a page for the linkconst png = (() => {// a valid PNG built with zlib: 64x64 RGB, one colourconst w = 64, h = 64, raw = Buffer.alloc((w * 3 + 1) * h);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; } }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; }const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcT[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };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]); };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;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))]);})();const site = createServer((req, res) => {if (req.url.startsWith('/icon.png')) { res.writeHead(200, { 'content-type': 'image/png' }); res.end(png); return; }res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });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>');});await new Promise(r => site.listen(8413, '127.0.0.1', r));await up(ID);// ---- helpers ----------------------------------------------------------------------------const lastCode = (email) => {const lines = readFileSync(join(G, 'main', 'mail.txt'), 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' '));return lines.length ? lines[lines.length - 1].split(' ')[1] : null;};let emitI = 0;// hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is// ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.const framework_refused = (raw) => { try { const j = JSON.parse(raw); return j.ok === false && /the `session` parameter is filled by the server/.test(j.error || ''); } catch { return false; } };async function emit(event, payload, cookie) {const r = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, cookie: (r.headers.get('set-cookie') || '').split(';')[0], value: j && j.value, raw: t };}async function api(path, body, method = 'POST') {const r = await fetch(ID + path, { method, headers: { 'content-type': 'application/json' }, ...(method === 'GET' ? {} : { body: typeof body === 'string' ? body : J(body) }) });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, j, t };}const idsOf = {};async function apiLogin(email) {const q = await fetch(ID + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ email }) });if (q.status !== 200) throw new Error('code request failed: ' + q.status);const v = await emit('verifyCode', [email, lastCode(email), 'UTC']);if (!v.value || !v.value.account || !v.cookie) throw new Error('api login failed: ' + v.raw);idsOf[v.cookie] = v.value.identities.map(i => i.id);return v.cookie;}// THE APP'S SIDE of a login: button → choice → exchange → the app-specific identity idasync function appIdentity(cookie, app, identity) {const l = await fetch(ID + '/login?key=' + app.key + '&return=' + encodeURIComponent(SITE + '/callback'), { redirect: 'manual' });const rid = (l.headers.get('location') || '').split('/').pop();const c = await emit('chooseIdentity', [rid, identity], cookie);const code = new URL(c.value.url).searchParams.get('ident_code');const x = await api('/api/exchange', { key: app.key, secret: app.secret, code });if (x.status !== 200) throw new Error('exchange failed: ' + x.t);return x.j.identity;}// THE STORED STATE, read off a COPY (opening a table rewrites it, hybriel #40)let copyN = 0;function store() {const copy = join(G, 'copy-' + (++copyN));cpSync(STORE, copy, { recursive: true });const out = execFileSync(join(APP, 'bin/hybriel'), ['tools/dump-store.hl'], { cwd: APP, env: { ...process.env, IDENT_STORAGE: copy }, encoding: 'utf8' });const line = out.split('\n').find(l => l.startsWith('{'));return JSON.parse(line);}const setting = (s, conn, kind) => s.settings.find(r => r.pair === conn + ':' + kind);const pages = [];async function ready(p) { await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'hydrated' }); }async function viewport(p, w) {await p.send('Emulation.setDeviceMetricsOverride', { width: w, height: w < 500 ? 844 : 900, deviceScaleFactor: 1, mobile: w < 500 });}async function shot(p, name) {for (const w of [390, 1280]) {await viewport(p, w);await sleep(200);const over = await p.evaluate('document.documentElement.scrollWidth > window.innerWidth');check(`${name} @${w}px: no horizontal overflow`, !over);const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });writeFileSync(join(SHOTS, `notify-${name}-${w}.png`), Buffer.from(data, 'base64'));}await viewport(p, 1280);}const txt = (p, sel) => p.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || ''`);const attr = (p, sel, a) => p.evaluate(`(document.querySelector(${J(sel)}) || { getAttribute: () => null }).getAttribute(${J(a)})`);const count = (p, sel) => p.evaluate(`document.querySelectorAll(${J(sel)}).length`);async function browserLogin(p, email) {await p.goto(ID + '/');await p.waitForSelector('#email');await ready(p);await p.type('#email', email);await p.click('#sendcode');await p.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'code page' });await ready(p); // ident#20: the code step is its own page (/code) — wait for it to hydrateawait p.type('#code', lastCode(email));await p.click('#verify');}let browser1, browser2;try {browser1 = await launchBrowser({ debugPortRange: CHROME_PORTS });browser2 = await launchBrowser({ debugPortRange: CHROME_PORTS });const p = await browser1.newPage(); pages.push(p);await viewport(p, 1280);// ==== 0. accounts, apps, per-app ids ==================================================console.log('# setup: alice (2 identities), bob, apps A and B, per-app ids');const ALICE = '[email protected]', BOB = '[email protected]';await p.goto(ID + '/inbox');check('signed out /inbox asks to sign in', /sign in/i.test(await txt(p, '#signinfirst')));await browserLogin(p, ALICE);await p.waitForSelector('#skip');await p.click('#skip');await p.waitFor('!document.querySelector("#identityform")');const alice = await apiLogin(ALICE); // a second session of the same account (for API calls)await emit('addIdentity', [{ identityName: 'Work' }], alice);const aliceIds = (await emit('editIdentity', [idsOf[alice][0], { identityName: 'Default' }], alice)).value.identities.map(i => i.id);check('alice has two identities', aliceIds.length === 2, J(aliceIds));const mkApp = async (name) => { const v = (await emit('appCreate', [{ name, origins: [SITE] }], alice)).value; return { key: v.app.apiKey, secret: v.secret }; };const A = await mkApp('Chat app'), B = await mkApp('Shop');const idA1 = await appIdentity(alice, A, aliceIds[0]); // alice Default in Aconst idA2 = await appIdentity(alice, A, aliceIds[1]); // alice Work in Aconst idB1 = await appIdentity(alice, B, aliceIds[0]); // alice Default in Bconst bob = await apiLogin(BOB);const idBob = await appIdentity(bob, A, idsOf[bob][0]); // bob in Acheck('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)));let s = store();const identOf = (short) => s.identities.find(i => i.shortId === short);const connOf = (app, short) => s.connections.find(c => c.identity === identOf(short).id && c.app === app);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);// ==== 1. the app registers its kinds ==================================================console.log('# kinds');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 }] });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);r = await api('/api/kinds', { key: A.key, secret: A.secret, kinds: [] });check('empty list → 200, lists the kinds', r.status === 200 && r.j.kinds.length === 2, r.t);const kindsBad = [[{ key: A.key, secret: B.secret, kinds: [] }, 401, /wrong secret/, 'wrong secret'],[{ key: 'pk_nope', secret: A.secret, kinds: [] }, 401, /wrong secret/, 'unknown key'],[{ key: A.key, secret: A.secret }, 400, /missing field: kinds/, 'missing kinds'],[{ key: A.key, secret: A.secret, kinds: {} }, 400, /must be a list/, 'kinds not a list'],[{ key: A.key, secret: A.secret, kinds: [], extra: 1 }, 400, /unknown field: extra/, 'unknown top field'],[{ key: A.key, secret: A.secret, kinds: [{ name: 'X', push: true, email: true, sound: 1 }] }, 400, /unknown field: kinds\[0\]\.sound/, 'unknown kind field'],[{ key: A.key, secret: A.secret, kinds: [{ name: 'X', push: true }] }, 400, /missing field: kinds\[0\]\.email/, 'missing kind field'],[{ key: A.key, secret: A.secret, kinds: [{ name: 'X', push: 'yes', email: true }] }, 400, /push must be a boolean/, 'push not boolean'],[{ key: A.key, secret: A.secret, kinds: [{ name: '', push: true, email: true }] }, 400, /empty/, 'empty name'],[{ key: A.key, secret: A.secret, kinds: [{ name: 'x'.repeat(61), push: true, email: true }] }, 400, /longer than 60/, 'name too long'],[{ key: A.key, secret: A.secret, kinds: [{ name: 'Y', push: true, email: true }, { name: 'Y', push: false, email: true }] }, 400, /twice/, 'duplicate name'],[{ key: A.key, secret: A.secret, kinds: ['X'] }, 400, /must be an object/, 'kind not an object'],];for (const [body, st, re, label] of kindsBad) {r = await api('/api/kinds', body);check(`kinds refused: ${label} → ${st}`, r.status === st && re.test(r.j && r.j.error), r.t);}r = await api('/api/kinds', { key: A.key, secret: A.secret, kinds: [{ name: 'Ok', push: true, email: true }, { name: 'Bad', push: 1, email: true }] });s = store();check('a refused call writes nothing (no kind "Ok")', r.status === 400 && !s.kinds.some(k => k.name === 'Ok'), r.t);r = await api('/api/kinds', '{"key":');check('kinds: invalid JSON → 400', r.status === 400 && /not valid JSON/.test(r.j.error), r.t);r = await api('/api/kinds', null, 'GET');check('kinds: GET → 405', r.status === 405, r.t);// ==== 2. the app sends ================================================================console.log('# send');const send = (body) => api('/api/notify', { key: A.key, secret: A.secret, ...body });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' });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);await sleep(20);const n2 = await send({ identity: idA2, name: 'Weekly digest', text: 'Your week in the chat app.', urgent: true });check('urgent notification without icon/link → 200', n2.status === 200, n2.t);await sleep(20);const n3 = await send({ identity: idA1, name: 'Mention', text: 'You were mentioned.', urgent: false });check('unregistered name → allowed (200)', n3.status === 200, n3.t);await sleep(20);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' });check('app B sends to its own id → 200', n4.status === 200, n4.t);await sleep(20);const nBob = await send({ identity: idBob, name: 'New comment', text: 'BOB-ONLY secret text' });check('app A sends to bob → 200', nBob.status === 200, nBob.t);const sendBad = [[{ key: A.key, secret: B.secret, identity: idA1, name: 'x', text: 'y' }, 401, /wrong secret/, 'wrong secret'],[{ key: 'pk_x', secret: A.secret, identity: idA1, name: 'x', text: 'y' }, 401, /wrong secret/, 'unknown key'],[{ key: B.key, secret: B.secret, identity: idA2, name: 'x', text: 'y' }, 404, /unknown identity/, "an identity that never logged in to this app"],[{ key: A.key, secret: A.secret, identity: '0'.repeat(32), name: 'x', text: 'y' }, 404, /unknown identity/, 'unknown identity id'],[{ key: A.key, secret: A.secret, identity: aliceIds[0], name: 'x', text: 'y' }, 404, /unknown identity/, "ident's own identity id"],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', email: '[email protected]' }, 400, /unknown field: email/, 'unknown field'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x' }, 400, /missing field: text/, 'missing text'],[{ key: A.key, secret: A.secret, identity: idA1, text: 'y' }, 400, /missing field: name/, 'missing name'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', urgent: 'yes' }, 400, /urgent must be a boolean/, 'urgent not boolean'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 5 }, 400, /text must be a string/, 'text not a string'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: ' ' }, 400, /text is empty/, 'empty text'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y'.repeat(2001) }, 400, /longer than 2000/, 'text too long'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'a\u0007b' }, 400, /control character/, 'control char in text'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'a\nb', text: 'y' }, 400, /control character/, 'newline in name'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', icon: 'javascript:alert(1)' }, 400, /http/, 'javascript: icon'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', link: 'ftp://a.b/' }, 400, /http/, 'ftp link'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', link: 'https://a.b/x y' }, 400, /not allowed/, 'space in link'],[{ key: A.key, secret: A.secret, identity: idA1, name: 'x', text: 'y', link: 'https:///x' }, 400, /no host/, 'link without host'],];for (const [body, st, re, label] of sendBad) {r = await api('/api/notify', body);check(`send refused: ${label} → ${st}`, r.status === st && re.test(r.j && r.j.error), r.t);}r = await api('/api/notify', '{"key":"a","secret":"b","identity":"c","name":"d","text":"\\ud83d\\ude00"}');check('send: surrogate escape → 400 (not 500)', r.status === 400, r.t);r = await api('/api/notify', '[1]');check('send: not an object → 400', r.status === 400 && /object/.test(r.j.error), r.t);r = await api('/api/notify', null, 'GET');check('send: GET → 405', r.status === 405, r.t);// what was stored: channels per notification, nothing delivereds = store();const note = (id) => s.notifications.find(n => n.id === id);check('stored: 5 notifications (refusals stored nothing)', s.notifications.length === 5, s.notifications.length);let x = note(n1.j.id);check('New comment (preset push on, email off): push, no mail', x && x.push === true && x.mail === 'none' && x.urgent === false, J(x));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));x = note(n2.j.id);check('urgent Weekly digest (email on, no push device): mail now', x && x.urgent === true && x.push === false && x.mail === 'now', J(x));x = note(n3.j.id);check('unregistered Mention: daily mail, no push', x && x.push === false && x.mail === 'daily', J(x));const mention = s.kinds.find(k => k.name === 'Mention');check('unregistered name stored as an unregistered kind (email on, push off)', mention && mention.registered === false && mention.email === true && mention.push === false, J(mention));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));// ==== 3. the inbox (browser) ==========================================================console.log('# inbox');await p.goto(ID + '/inbox');await p.waitForSelector('#inbox'); await ready(p);const names = await p.evaluate('[...document.querySelectorAll("#inbox li note-name")].map(e => e.textContent)');check('inbox: alice sees her 4 (both identities, both apps), newest first', J(names) === J(['Invoice', 'Mention', 'Weekly digest', 'New comment']), J(names));check('inbox: bob\'s notification is not there', !(await p.evaluate('document.body.textContent.includes("BOB-ONLY")')));check('inbox: 4 unread', (await txt(p, '#unreadcount')) === '4 unread', await txt(p, '#unreadcount'));const row = (n) => `#inbox li:nth-child(${n})`;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')));check('inbox: text with its line break', (await p.evaluate(`document.querySelector("${row(4)} note-text").innerText`)).includes('post:\n“Nice one!”'));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);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);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');check('inbox: urgent badge only on the urgent one', (await count(p, row(3) + ' urgent-badge')) === 1 && (await count(p, '#inbox urgent-badge')) === 1);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'));check('inbox: all unread', (await count(p, '#inbox li.unread')) === 4);await shot(p, 'inbox');// mark read / unreadawait p.click(row(4) + ' .toggle');await p.waitFor(`document.querySelector("${row(4)}").className === "read"`, { label: 'marked read' });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');await p.goto(ID + '/inbox'); await ready(p);check('read state survives a reload', (await attr(p, row(4), 'class')) === 'read' && (await txt(p, '#unreadcount')) === '3 unread');s = store();check('stored: read = true, readAt set', note(n1.j.id).read === true && note(n1.j.id).readAt > 0);await p.click(row(3) + ' .toggle');await p.waitFor(`document.querySelector("${row(3)}").className === "read"`);await p.click(row(3) + ' .toggle');await p.waitFor(`document.querySelector("${row(3)}").className === "unread"`, { label: 'marked unread' });check('mark unread again', (await txt(p, '#unreadcount')) === '3 unread');await shot(p, 'inbox-read');// the action link opens the appconst href = await attr(p, row(4) + ' a.action', 'href');check('action link target answers (the app)', (await (await fetch(href)).text()).includes('/post/7'));// ==== 4. the per-app page ===============================================================console.log('# per-app page');const conns = await p.evaluate('[...document.querySelectorAll("#conns li")].map(li => li.textContent)');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));await p.click(`#conns a[href="/inbox/${cA1.id}"]`);await p.waitFor(`location.pathname === "/inbox/${cA1.id}" && !!document.querySelector("#kinds")`, { label: 'per-app page' });await ready(p);const since = new Date(cA1.created).toISOString();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);const kindNames = await p.evaluate('[...document.querySelectorAll("#kinds kind-name")].map(e => e.textContent)');check('per-app page: all the app\'s notifications by name', J(kindNames) === J(['Mention', 'New comment', 'Weekly digest']), J(kindNames));const k = (n, ch) => `#kinds li:nth-child(${n}) .switch.${ch}`;const state = async () => ({ov: [await attr(p, '#ovactive', 'aria-checked'), await attr(p, '#ovpush', 'aria-checked'), await attr(p, '#ovemail', 'aria-checked')],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")])'),});let st = await state();check('switches show the presets (Mention: no push)', J(st.kinds) === J([['none', 'true'], ['true', 'false'], ['false', 'true']]), J(st));check('override off, its switches: push off, email on', J(st.ov) === J(['false', 'false', 'true']), J(st.ov));check('Mention says it is not registered', /not registered/.test(await txt(p, '#kinds li:nth-child(1) kind-preset')));await shot(p, 'appsettings');// flip: New comment email on, Weekly digest push onawait p.click(k(2, 'email'));await p.waitFor(`document.querySelector(${J(k(2, 'email'))}).getAttribute("aria-checked") === "true"`, { label: 'email switched on' });await p.click(k(3, 'push'));await p.waitFor(`document.querySelector(${J(k(3, 'push'))}).getAttribute("aria-checked") === "true"`, { label: 'push switched on' });check('flip shows "Saved."', (await txt(p, '#notice')) === 'Saved.');s = store();let nc = setting(s, cA1.id, 'New comment'), wd = setting(s, cA1.id, 'Weekly digest');check('stored: New comment user email on → effective push on + email on', nc.userEmail === 'on' && nc.push === true && nc.email === true, J(nc));check('stored: Weekly digest user push on → effective push on + email on', wd.userPush === 'on' && wd.push === true && wd.email === true, J(wd));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')));await p.goto(ID + '/inbox/' + cA1.id); await ready(p);st = await state();check('switches survive a reload', J(st.kinds) === J([['none', 'true'], ['true', 'true'], ['true', 'true']]), J(st));// a notification now follows the new effective settingsconst n5 = await send({ identity: idA1, name: 'New comment', text: 'Another comment.' });s = store();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)));// THE OVERRIDEawait p.click('#ovactive');await p.waitFor('document.querySelector("#ovactive").getAttribute("aria-checked") === "true"', { label: 'override on' });st = await state();check('override on: every kind = push off, email on', J(st.kinds) === J([['none', 'true'], ['false', 'true'], ['false', 'true']]), J(st));check('override on: per-kind switches locked', (await count(p, '#kinds .switch.locked')) === 5, await count(p, '#kinds .switch.locked'));await p.click('#ovemail');await p.waitFor('document.querySelector("#ovemail").getAttribute("aria-checked") === "false"', { label: 'override email off' });await p.click('#ovpush');await p.waitFor('document.querySelector("#ovpush").getAttribute("aria-checked") === "true"', { label: 'override push on' });st = await state();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));s = store();const ovr = setting(s, cA1.id, '');check('stored: the override row', ovr && ovr.active === true && ovr.push === true && ovr.email === false, J(ovr));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)));check('stored: the user\'s own switches kept under the override', setting(s, cA1.id, 'New comment').userEmail === 'on');await shot(p, 'override');const n6 = await send({ identity: idA1, name: 'Mention', text: 'Mentioned again.', urgent: true });const n7 = await send({ identity: idA1, name: 'Weekly digest', text: 'Digest.' });s = store();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)));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)));// clicking a locked switch changes nothingawait p.click(k(2, 'email'));await sleep(300);check('a locked switch does nothing', (await attr(p, k(2, 'email'), 'aria-checked')) === 'false');await p.click('#ovactive');await p.waitFor('document.querySelector("#ovactive").getAttribute("aria-checked") === "false"', { label: 'override off' });st = await state();check('override off: the user\'s own switches are back', J(st.kinds) === J([['none', 'true'], ['true', 'true'], ['true', 'true']]), J(st));s = store();check('stored effective back to the user\'s switches', setting(s, cA1.id, 'New comment').email === true && setting(s, cA1.id, 'Mention').email === true);// the app changes its presets / registers the unregistered namer = await api('/api/kinds', { key: A.key, secret: A.secret, kinds: [{ name: 'Mention', push: true, email: false }, { name: 'New comment', push: false, email: false }] });check('app re-registers: Mention now registered', r.status === 200 && r.j.kinds.length === 3, r.t);s = store();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')]));check('…and the user\'s switches still win', setting(s, cA1.id, 'New comment').email === true, J(setting(s, cA1.id, 'New comment')));await p.goto(ID + '/inbox/' + cA1.id); await ready(p);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');// ==== 5. faces: forged sessions, strict, other accounts ===============================console.log('# faces');const forged = { user: { id: s.accounts.find(a => a.email === ALICE).id } };for (const [ev, args] of [['inboxMark', [n1.j.id, true]], ['inboxSwitch', [cA1.id, 'New comment', 'email', false]], ['inboxOverride', [cA1.id, 'active', true]]]) {const f = await emit(ev, [...args, forged]);check(`forged session argument refused: ${ev}`, framework_refused(f.raw) || (f.value && /not signed in/.test(f.value.error)), f.raw);const f2 = await emit(ev, args);check(`no session refused: ${ev}`, f2.value && /not signed in/.test(f2.value.error), f2.raw);}let e = await emit('inboxMark', [n1.j.id, false], bob);check("bob cannot mark alice's notification", e.value.error === 'no such notification', e.raw);e = await emit('inboxSwitch', [cA1.id, 'New comment', 'email', false], bob);check("bob cannot switch alice's settings", e.value.error === 'no such app connection', e.raw);e = await emit('inboxOverride', [cA1.id, 'active', true], bob);check("bob cannot set alice's override", e.value.error === 'no such app connection', e.raw);const bobPage = await (await fetch(ID + '/inbox/' + cA1.id, { headers: { cookie: bob } })).text();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'));const bobInbox = await (await fetch(ID + '/inbox', { headers: { cookie: bob } })).text();check("bob's inbox: only his own", bobInbox.includes('BOB-ONLY') && !bobInbox.includes('Invoice') && !bobInbox.includes('Mention'));s = store();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);e = await emit('inboxMark', [n1.j.id, 'yes'], alice);check('inboxMark: read not a boolean → refused', /must be a boolean/.test(e.value.error), e.raw);e = await emit('inboxMark', [5, true], alice);check('inboxMark: id not a string → refused', e.value.error === 'no such notification', e.raw);e = await emit('inboxSwitch', [cA1.id, 'New comment', 'sms', true], alice);check('inboxSwitch: unknown channel refused', /push or email/.test(e.value.error), e.raw);e = await emit('inboxSwitch', [cA1.id, 'New comment', 'email', 'on'], alice);check('inboxSwitch: value not a boolean refused', /must be a boolean/.test(e.value.error), e.raw);e = await emit('inboxSwitch', [cA1.id, 'Nope', 'email', true], alice);check('inboxSwitch: unknown kind refused', /no such notification kind/.test(e.value.error), e.raw);e = await emit('inboxSwitch', [cB1.id, 'Invoice', 'push', true], alice);check('inboxSwitch: push on an unregistered kind refused', /cannot be pushed/.test(e.value.error), e.raw);e = await emit('inboxSwitch', [7, 'Invoice', 'email', true], alice);check('inboxSwitch: connection not a string refused', /must be a string/.test(e.value.error), e.raw);e = await emit('inboxOverride', [cA1.id, 'sound', true], alice);check('inboxOverride: unknown field refused', /active, push or email/.test(e.value.error), e.raw);e = await emit('inboxSwitch', [cBob.id, 'New comment', 'email', true], alice);check("alice cannot switch bob's connection", e.value.error === 'no such app connection', e.raw);const unknownPage = await (await fetch(ID + '/inbox/zzzzzzzzzzzz', { headers: { cookie: alice } })).text();check('unknown connection id → not found page', unknownPage.includes('does not exist or is not yours'));const signedOutPage = await (await fetch(ID + '/inbox/' + cA1.id)).text();check('signed out per-app page → sign in', signedOutPage.includes('Sign in to ident first') && !signedOutPage.includes('Weekly digest'));// ==== 6. a second browser: bob sees only his own ======================================console.log('# second browser (bob)');const q = await browser2.newPage(); pages.push(q);await viewport(q, 390);await browserLogin(q, BOB);await q.waitFor('!!document.querySelector("#identities")');await q.goto(ID + '/inbox'); await q.waitForSelector('#inbox'); await ready(q);check('bob (browser): one notification, his', (await count(q, '#inbox li')) === 1 && (await txt(q, '#inbox li note-text')) === 'BOB-ONLY secret text');await q.goto(ID + '/inbox/' + cA1.id);check("bob (browser): alice's per-app page refused", /not yours/.test(await txt(q, '#notfound')));// ==== 7. console ======================================================================const probs = pages.flatMap(x => x.problems().map(m => m.text));check('no console errors or warnings', probs.length === 0, probs.join(' | '));} catch (err) {failed++;console.log(' FAIL (aborted) ' + (err && err.stack || err));for (const x of pages) console.log('--- console:\n' + x.dumpConsole());} finally {for (const b of [browser1, browser2]) { if (b) { try { await b.close(); } catch {} } }for (const { p } of procs) { try { p.kill(); } catch {} }site.close();await sleep(300);}console.log(`\n${passed} passed, ${failed} failed`);process.exit(failed ? 1 : 0);
Branches
- mainmain branch
Latest commits
- d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
- 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
- f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
- ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
- a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
- 98226b41antcolony#40: mission references point to the moved missionsmre
- ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
- 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
- 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
- 81b15b7bState of 2026-09-27, before the move to gitoriamre