gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit38bdd5e438bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre38bdd5e4/tests/browser.mjs

72.9 KB

  1. // tests/browser.mjs — THE GATE of tickets.worldapi.org: its own server on its own
  2. // storage, the import command, the JSON API, and TWO real headless Chromes (two
  3. // viewers) for the live push. Everything is checked in the browser's DOM, not in
  4. // curl output. Ticket #10 added: the strict API table (unknown / missing / empty /
  5. // wrong-type fields → 400 naming the field, nothing written), Vienna local time (checked
  6. // against node's Intl, also tests/localtime.hl's DST edges), and the paragraph import
  7. // (a CRLF fixture in .scratch/gate-import → #7, rendered with a blank line between).
  8. // Ticket #15 added: `author` required on every POST (missing/empty → 400 naming author),
  9. // invalid JSON → 400 { error } without source paths (jsoncheck.hl), the web forms have no
  10. // name field (author 'creator'), and list lines kept by the import (.scratch/gate-import-lists → hybriel #3).
  11. // Ticket #38 (mission 008) added: UUID keys + storage in <store>/mpackdb, per-project
  12. // numbers and URLs /projects/<slug>/<number>. The gate FIRST writes an old-format store
  13. // (tests/oldstore.hl: 5 tickets, old #1–#5 in projects alpha / beta.example.org), migrates
  14. // it TWICE with tools/migrate-008.hl (the second run must write nothing), and runs the
  15. // server on the migrated tables: old numbers answer on /api/tickets/<n>, /tickets/<n>
  16. // redirects (301) to the new URL, the per-project API, live pushes carrying the new hrefs.
  17. // Ticket #7 (mission 011) added LOGIN VIA IDENT: the gate runs its OWN ident (a copy of
  18. // ident's code without .env / storage — codes go to a mail sink, never real mail; tests/identkit.mjs)
  19. // on :8353, registers tickets there (origin = this gate's server), and then: every API write
  20. // needs `Authorization: Bearer <token>` (401 otherwise, checked before the body; `author` in a
  21. // body → 400), a machine user "gate" logs in through the login button's server half
  22. // (/login/callback) and makes its token over the face; the FIRST server runs without
  23. // TICKETS_CREATOR_IDENTITY (nobody may confirm/reject → 403), the second with alice's per-app id.
  24. // In the browsers: signed out = reading only; A (alice, the creator) logs in with the identity
  25. // SELECTOR, B (bob) with the LOGIN BUTTON; both get the display-name prompt; writes show the
  26. // user as author; bob cannot confirm/reject, alice can; bob's token on /you: shown once → API
  27. // write as bob → revoke → 401; forged face sessions (#31) are refused; old events keep their
  28. // authors; logout resets the selector.
  29. // Ticket #12 (mission 024) added THE MARKDOWN EDITOR: comment, state note, edit summary and new-ticket
  30. // summary are <md-editor>s around their textareas (shared/md-editor.js). Real typing (formatting
  31. // while typing, "- " lists, Ctrl+Enter sends), the toolbar by click / tap at 390px, a hostile rich
  32. // paste reduced to the subset, the edit form opening the stored Markdown byte for byte, SSR keeps
  33. // the plain textareas (no JS = as before). The component's own test: worldapi-components/test/run.mjs.
  34. //
  35. // node tests/browser.mjs (TICKETS_GATE_PORT to move the server, default 8352;
  36. // TICKETS_GATE_IDENT_PORT the gate's ident, default 8353;
  37. // TICKETS_GATE_IDENT_DIR ident's code, default ../ident.worldapi.org)
  38. //
  39. // Debug ports: 8620-8629 (browser A) and 8630-8639 (browser B) — TICKETS_GATE_CHROME_A /
  40. // TICKETS_GATE_CHROME_B ("8810-8814") move them (mission 017: parallel workers get disjoint
  41. // port ranges). Screenshots at 390px
  42. // and 1280px land in .scratch/gate-*.png — LOOK at them. The whole server log is kept in
  43. // .scratch/gate-server.log. Every process started here (server, import runs, Chromes)
  44. // is stopped by PID in `finally`.
  45. import { spawn, spawnSync } from 'node:child_process';
  46. import http from 'node:http';
  47. import { rmSync, mkdirSync, writeFileSync, existsSync, readdirSync, copyFileSync } from 'node:fs';
  48. import { dirname, join, resolve } from 'node:path';
  49. import { fileURLToPath } from 'node:url';
  50. import { launchBrowser } from './cdp.mjs';
  51. import { startIdent } from './identkit.mjs';
  52. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  53. const HERE = dirname(fileURLToPath(import.meta.url));
  54. const APP = resolve(HERE, '..');
  55. const BIN = join(APP, 'bin/hybriel');
  56. const PORT = Number(process.env.TICKETS_GATE_PORT || 8352);
  57. const BASE = `http://127.0.0.1:${PORT}`;
  58. const SCRATCH = join(APP, '.scratch');
  59. const STORE = join(SCRATCH, 'gate-store');
  60. const IDENT_PORT = Number(process.env.TICKETS_GATE_IDENT_PORT || 8353);
  61. // ident's CODE (read only — copied without .env/storage by identkit): the sibling folder,
  62. // or the Loreana path when the gate runs in a copy (.scratch/dev…)
  63. const IDENT_DIR = process.env.TICKETS_GATE_IDENT_DIR || [resolve(APP, '../ident.worldapi.org'), '/media/STORAGE/projects/ident.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));
  64. rmSync(STORE, { recursive: true, force: true });
  65. mkdirSync(join(STORE, 'mpackdb'), { recursive: true });
  66. let failures = 0, passes = 0;
  67. function check(label, ok, detail = '') {
  68. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  69. if (ok) passes++; else failures++;
  70. }
  71. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  72. const J = JSON.stringify;
  73. // ---- the legacy fixture (ticket #20): a store from before projects were own data; the server's migrate.hl
  74. // turns it into projects with ids, members and the new states at its first start ----------------------
  75. const hl = (script, env) => { const r = spawnSync(BIN, [script], { cwd: APP, env: { ...process.env, ...env }, encoding: 'utf8', timeout: 60000 }); return (r.stdout || '') + (r.stderr || ''); };
  76. const fixture = hl('tests/oldstore.hl', { TICKETS_STORAGE: join(STORE, 'mpackdb') });
  77. check('migrate: the pre-#20 fixture is written (2 projects, 5 tickets, 8 events)', /oldstore: 2 projects, 5 tickets, 8 events/.test(fixture), fixture);
  78. // ---- the servers: our own ident, then tickets (twice: without and with a creator) ----------
  79. let log = '';
  80. let server = null;
  81. let ident = null;
  82. // ticket #9 (mission 012): the exchanges ident RECEIVES from tickets, counted by a small proxy
  83. // between the two (tickets' IDENT_EXCHANGE_URL → here → our ident)
  84. const EXCH_PORT = Number(process.env.TICKETS_GATE_EXCHANGE_PORT || 8357);
  85. const exchanges = [];
  86. const exchProxy = http.createServer((req, res) => {
  87. let body = '';
  88. req.on('data', d => body += d);
  89. req.on('end', async () => {
  90. try {
  91. const r = await fetch(`http://127.0.0.1:${IDENT_PORT}${req.url}`, { method: req.method, headers: { 'content-type': req.headers['content-type'] || 'application/json' }, body: req.method === 'GET' ? undefined : body });
  92. const t = await r.text();
  93. let code = ''; try { code = JSON.parse(body).code || ''; } catch {}
  94. exchanges.push({ path: req.url, code, status: r.status });
  95. res.writeHead(r.status, { 'content-type': r.headers.get('content-type') || 'application/json' });
  96. res.end(t);
  97. } catch (e) { req.socket.destroy(); } // ident stopped: tickets must see NO answer, like a direct connection
  98. });
  99. });
  100. await new Promise((ok, bad) => { exchProxy.once('error', bad); exchProxy.listen(EXCH_PORT, '127.0.0.1', ok); });
  101. let APPKEY = null;
  102. function startTickets(extraEnv) {
  103. log += `\n==== tickets server start ${J(Object.keys(extraEnv))}\n`;
  104. server = spawn(BIN, ['project.hl'], {
  105. cwd: APP,
  106. env: { ...process.env, TICKETS_PORT: String(PORT), TICKETS_STORAGE: join(STORE, 'mpackdb'), TICKETS_SESSIONS: join(STORE, 'sessions'),
  107. IDENT_URL: `http://127.0.0.1:${IDENT_PORT}`, IDENT_EXCHANGE_URL: `http://127.0.0.1:${EXCH_PORT}`, TICKETS_PUBLIC_URL: BASE, IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret,
  108. TICKETS_CREATOR_IDENTITY: '', TICKETS_WATCH: '0', ...extraEnv },
  109. stdio: ['ignore', 'pipe', 'pipe'],
  110. });
  111. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  112. }
  113. async function stopTickets() {
  114. if (!server) return;
  115. const s = server;
  116. try { s.kill('SIGTERM'); } catch {}
  117. await new Promise(r => { if (s.exitCode !== null || s.signalCode !== null) return r(); s.once('exit', r); setTimeout(r, 3000); });
  118. }
  119. async function ticketsUp() {
  120. for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/'); if (r.ok) return; } catch {} await sleep(250); }
  121. throw new Error('server did not come up\n' + log);
  122. }
  123. // THE API: reads never carry a token (reading is public); writes carry the gate user's
  124. // token unless another (or none: null) is given
  125. let TOKEN = null;
  126. const api = async (method, path, body, token = TOKEN) => {
  127. const headers = body ? { 'content-type': 'application/json' } : {};
  128. if (method !== 'GET' && token) headers.authorization = 'Bearer ' + token;
  129. const r = await fetch(BASE + path, { method, headers, body: body ? J(body) : undefined });
  130. let json = null; try { json = await r.json(); } catch {}
  131. return { status: r.status, json };
  132. };
  133. const runImport = (token = TOKEN) => {
  134. const r = spawnSync(BIN, ['import.hl'], { cwd: APP, env: { ...process.env, TICKETS_URL: BASE, TICKETS_TOKEN: token || '' }, encoding: 'utf8', timeout: 60000 });
  135. return (r.stdout || '') + (r.stderr || '');
  136. };
  137. // a face over the REST carrier (POST /__hl/emit) with a cookie (or none)
  138. let emitI = 0;
  139. // hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is
  140. // ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.
  141. 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; } };
  142. const temit = async (event, payload, cookie) => {
  143. const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });
  144. const raw = await r.text();
  145. let j = null; try { j = JSON.parse(raw); } catch {}
  146. return { status: r.status, value: j ? j.value : undefined, raw };
  147. };
  148. const cookieOf = (r) => (r.headers.get('set-cookie') || '').split(';')[0];
  149. const texts = (page, sel) => page.evaluate(`Array.from(document.querySelectorAll(${J(sel)})).map(e => e.textContent.trim())`);
  150. const type = (page, sel, value) => page.evaluate(`(() => { const i = document.querySelector(${J(sel)}); i.value = ${J(value)}; i.dispatchEvent(new Event("input", { bubbles: true })); })()`);
  151. const choose = (page, sel, value) => page.evaluate(`(() => { const i = document.querySelector(${J(sel)}); i.value = ${J(value)}; i.dispatchEvent(new Event("change", { bubbles: true })); })()`);
  152. const connected = (page, label) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label });
  153. // a client-side navigation by a REAL click, then the socket announced the new mounts
  154. const clickNav = async (page, sel, cond, label) => { await page.click(sel); await page.waitFor(cond, { label }); await sleep(300); };
  155. async function viewport(page, width, height) {
  156. await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });
  157. await sleep(250);
  158. }
  159. async function shot(page, name) {
  160. const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  161. writeFileSync(join(SCRATCH, `gate-${name}.png`), Buffer.from(data, 'base64'));
  162. }
  163. const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');
  164. // INSIDE ident's selector (shadow DOM): an expression over its root `r`, and a REAL click
  165. const sh = (expr) => `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; return (${expr}); })()`;
  166. async function shClick(page, inner, name = null) {
  167. const find = `(() => { const h = document.querySelector('#selector'); const r = h && h.shadowRoot; if (!r) return null; const el = ${name === null ? `r.querySelector(${J(inner)})` : `[...r.querySelectorAll(${J(inner)})].find(b => b.textContent === ${J(name)})`}; if (!el) return null; el.scrollIntoView({ block: 'center' }); const b = el.getBoundingClientRect(); if (!b.width) return null; return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; })()`;
  168. const box = await page.waitFor(find, { label: 'selector ' + inner + ' ' + (name || '') });
  169. const at = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1 };
  170. await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...at, buttons: 0 });
  171. await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at, buttons: 1 });
  172. await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at, buttons: 0 });
  173. }
  174. const connectedIdent = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'ident page hydrated' });
  175. // sign in to OUR ident on ident's own page (email → code from the sink → skip the names)
  176. async function identSignIn(page, email) {
  177. await page.goto(ident.base + '/');
  178. await page.waitForSelector('#email');
  179. await connectedIdent(page);
  180. await page.type('#email', email);
  181. await page.click('#sendcode');
  182. // ident#20: a sent code NAVIGATES to ident's /code page; type only once that page is hydrated
  183. await page.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'ident /code page' });
  184. await connectedIdent(page);
  185. await page.type('#code', ident.lastCode(email));
  186. await page.click('#verify');
  187. await page.waitForSelector('#signout', { timeout: 10000 });
  188. }
  189. // TICKET #11 (mission 014): the gate must stay green while other browsers load the machine.
  190. // Every wait of a page (waitFor / waitForSelector / click / goto) gets SLOW× its timeout
  191. // (TICKETS_GATE_SLOW, default 3: 10 s → 30 s) — a wait that succeeds returns at once, so a green
  192. // run costs nothing; only a real failure waits longer before it says so.
  193. const SLOW = Number(process.env.TICKETS_GATE_SLOW || 3);
  194. function patient(page) {
  195. const waitFor = page.waitFor.bind(page);
  196. page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * SLOW });
  197. const goto = page.goto.bind(page);
  198. page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * SLOW });
  199. return page;
  200. }
  201. let A, B;
  202. try {
  203. // ---- ticket #7: our own ident, tickets registered in it ----------------------------------
  204. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  205. const alice = await ident.signIn('[email protected]');
  206. const bob = await ident.signIn('[email protected]');
  207. const gateAcct = await ident.signIn('[email protected]');
  208. APPKEY = await ident.registerApp(alice, 'tickets (gate)', [BASE]);
  209. check('ident: our own ident runs (a copy without .env), tickets is registered (key + secret)', /^pk_[0-9a-f]{32}$/.test(APPKEY.key) && /^sk_[0-9a-f]{48}$/.test(APPKEY.secret) && !existsSync(join(STORE, 'ident', 'ident-code', '.env')));
  210. // alice's per-app id — what the architect puts into TICKETS_CREATOR_IDENTITY
  211. const ALICE_ID = await ident.exchange(APPKEY, await ident.selectorCode(alice, APPKEY, BASE));
  212. const GATE_ID = await ident.exchange(APPKEY, await ident.selectorCode(gateAcct, APPKEY, BASE));
  213. check('ident: one identity id per identity (a short id, or the old 32 hex)', /^[0-9a-z]{5,64}$/.test(ALICE_ID) && /^[0-9a-z]{5,64}$/.test(GATE_ID) && ALICE_ID !== GATE_ID, ALICE_ID + ' ' + GATE_ID);
  214. // ---- server #1: NO creator configured ---------------------------------------------------
  215. startTickets({});
  216. await ticketsUp();
  217. // the machine user "gate": the login button's SERVER half (ident → /login/callback?ident_code=)
  218. const first = await fetch(BASE + '/');
  219. const GATE_COOKIE = cookieOf(first);
  220. check('login: a page visit gets the tickets session cookie (hlsid)', /^hlsid=[0-9a-f]+$/.test(GATE_COOKIE), GATE_COOKIE);
  221. const noCode = await fetch(BASE + '/login/callback', { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  222. check('login: /login/callback without a code → 400 page, no redirect', noCode.status === 400 && !noCode.headers.get('location') && /no login code/.test(await noCode.text()));
  223. const badCode = await fetch(BASE + '/login/callback?ident_code=' + 'ab'.repeat(24), { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  224. check('login: an unknown code → 400 "ident refused the login", no redirect', badCode.status === 400 && !badCode.headers.get('location') && /ident refused the login \(400/.test(await badCode.text()));
  225. const gateCode = await ident.selectorCode(gateAcct, APPKEY, BASE);
  226. const cb = await fetch(BASE + '/login/callback?ident_code=' + gateCode, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  227. check('login: /login/callback exchanges the code → 302 to /', cb.status === 302 && cb.headers.get('location') === '/', cb.status + ' ' + cb.headers.get('location'));
  228. const again = await fetch(BASE + '/login/callback?ident_code=' + gateCode, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  229. check('login: the same code again → 400 (single use)', again.status === 400 && /already used/.test(await again.text()));
  230. // ticket #10: the button's return URL carries ?next=<page> (login.js); /login/callback follows
  231. // only a same-origin PATH, anything else → /
  232. for (const [next, want] of [
  233. ['/projects/alpha/2', '/projects/alpha/2'], ['/project/alpha/state/open?x=1&y=2', '/project/alpha/state/open?x=1&y=2'], ['/inbox', '/inbox'],
  234. ['/%2F%2Fevil.example', '/%2F%2Fevil.example'],
  235. ['//evil.example/x', '/'], ['https://evil.example/', '/'], ['http:/evil.example', '/'], ['/\\evil.example', '/'], ['javascript:alert(1)', '/'],
  236. ['evil.example', '/'], ['/a b', '/'], ['/x\r\nSet-Cookie: a=b', '/'], ['/x"><script>', '/'], ['/login/callback', '/'], ['/' + 'a'.repeat(500), '/'], ['', '/'],
  237. ]) {
  238. const c = await ident.selectorCode(gateAcct, APPKEY, BASE);
  239. const r = await fetch(BASE + '/login/callback?next=' + encodeURIComponent(next) + '&ident_code=' + c, { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  240. check(`return (#10): next=${J(next.length > 40 ? next.slice(0, 20) + '…(' + next.length + ')' : next)} → 302 ${want}`, r.status === 302 && r.headers.get('location') === want, r.status + ' ' + r.headers.get('location'));
  241. }
  242. let html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();
  243. check('login: first login → the page asks for a display name, no write form yet', html.includes('id="nameform"') && !html.includes('id="newticketform"') && html.includes('class="in"'));
  244. check('login: the page never carries the identity id (only /you does)', !html.includes(GATE_ID));
  245. let f = await temit('commentOn', ['x', 'before the name'], GATE_COOKIE);
  246. check('login: no writing before a display name', f.value && /display name first/.test(f.value.error), f.raw);
  247. f = await temit('saveDisplayName', [' '], GATE_COOKIE);
  248. check('name: an empty display name is refused', f.value && /must not be empty/.test(f.value.error), f.raw);
  249. f = await temit('saveDisplayName', ['x'.repeat(61)], GATE_COOKIE);
  250. check('name: 61 characters are refused', f.value && /too long/.test(f.value.error), f.raw);
  251. f = await temit('saveDisplayName', ['gate'], GATE_COOKIE);
  252. check('name: "gate" saved (answer: name, named — no identity id)', f.value && f.value.name === 'gate' && f.value.named === true && !f.raw.includes(GATE_ID), f.raw);
  253. f = await temit('saveDisplayName', ['gate2'], GATE_COOKIE);
  254. check('name: asked once — a second name is refused', f.value && /already set/.test(f.value.error), f.raw);
  255. html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();
  256. check('name: afterwards no prompt; no role yet → no ticket form, a role hint (ticket #20)', !html.includes('id="nameform"') && !html.includes('id="newticketform"') && html.includes('id="rolehint"'));
  257. f = await temit('tokenCreate', ['gate machine'], GATE_COOKIE);
  258. TOKEN = f.value && f.value.token;
  259. check('token: created over the face — tkt_ + 48 hex, listed with its label', /^tkt_[0-9a-f]{48}$/.test(TOKEN || '') && f.value.tokens.length === 1 && f.value.tokens[0].label === 'gate machine' && !J(f.value.tokens).includes(TOKEN), f.raw);
  260. const you0 = await (await fetch(BASE + '/you', { headers: { cookie: GATE_COOKIE } })).text();
  261. check('/you shows the user its own ident id and "not a member of any project yet"', you0.includes(GATE_ID) && /not a member of any project yet/.test(you0) && !you0.includes(TOKEN));
  262. // ticket #20: the legacy projects have NO admin yet (no creator configured) and the gate user is no member: every write is refused
  263. const nm1 = await api('POST', '/api/tickets/1/comments', { text: 'not a member' });
  264. const nm2 = await api('POST', '/api/projects/alpha/tickets/1/state', { state: 'review' });
  265. const nm3 = await api('POST', '/api/projects/alpha/tickets', { subject: 'not a member' });
  266. check('roles: a non-member cannot comment / change the state / open a ticket → 403, nothing written', nm1.status === 403 && nm2.status === 403 && nm3.status === 403 && (await api('GET', '/api/tickets/1')).json.events.length === 3, J([nm1, nm2, nm3]) + J([(await api('GET', '/api/projects/alpha')).status, (await api('GET', '/api/tickets/1')).status]) + log.slice(-900) + log.slice(-600));
  267. check('migration: at the first start the log says what was migrated', /migrated: 2 project\(s\) turned into records/.test(log) && /migrated: 5 ticket\(s\) linked to their project by id/.test(log), log.slice(0, 400));
  268. await stopTickets();
  269. // ---- server #2: the gate user is the creator → admin of the legacy projects (its session + token survive) --
  270. startTickets({ TICKETS_CREATOR_IDENTITY: GATE_ID });
  271. await ticketsUp();
  272. html = await (await fetch(BASE + '/', { headers: { cookie: GATE_COOKIE } })).text();
  273. check('restart: the gate user is still logged in (session on disk)', /id="whoami"[^>]*>gate</.test(html), html.slice(0, 200));
  274. // ---- the migrated legacy tickets: old numbers still answer (ticket #38) ---------------
  275. const PT = (slug, n) => `/api/projects/${slug}/tickets/${n}`;
  276. const old1 = await api('GET', '/api/tickets/1');
  277. check('legacy: GET /api/tickets/1 (old number) answers alpha #1 with project, number, href, oldNumber',
  278. old1.status === 200 && old1.json.ticket.project === 'alpha' && old1.json.ticket.number === 1 && old1.json.ticket.ref === '#1' && old1.json.ticket.oldNumber === 1
  279. && old1.json.ticket.href === '/projects/alpha/1' && old1.json.ticket.apiHref === PT('alpha', 1) && /^[0-9a-z]{8,}$/.test(old1.json.ticket.id), J(old1.json).slice(0, 500));
  280. check('legacy: history kept in order with authors, texts and times (same ms → old order)',
  281. J(old1.json.events.map(e => [e.seq, e.kind, e.author, e.text, e.to, e.createdMs])) === J([[1, 'created', 'creator', '', '', 1790000000000], [2, 'comment', 'worker', 'legacy comment on one', '', 1790000300000], [3, 'state', 'worker', 'started', 'progress', 1790000300000]]), J(old1.json.events));
  282. check('legacy: state, subject, times and event count kept', old1.json.ticket.state === 'progress' && old1.json.ticket.subject === 'Legacy alpha one' && old1.json.ticket.createdMs === 1790000000000 && old1.json.ticket.updatedMs === 1790000300000 && old1.json.ticket.events === 3, J(old1.json.ticket));
  283. const a2 = await api('GET', PT('alpha', 2));
  284. check('legacy: GET /api/projects/alpha/tickets/2 is old #3 (per-project form)', a2.status === 200 && a2.json.ticket.oldNumber === 3 && a2.json.ticket.subject === 'Question: legacy alpha two' && J(a2.json.events.map(e => e.kind)) === J(['created', 'comment']), J(a2.json).slice(0, 300));
  285. check('legacy: old #4 is alpha #3, old #5 is beta.example.org #2', (await api('GET', '/api/tickets/4')).json.ticket.href === '/projects/alpha/3' && (await api('GET', '/api/tickets/5')).json.ticket.href === '/projects/beta.example.org/2');
  286. const byUuid = await api('GET', '/api/tickets/' + old1.json.ticket.id);
  287. check('legacy: /api/tickets/<uuid> answers the same ticket', byUuid.status === 200 && byUuid.json.ticket.href === '/projects/alpha/1');
  288. check('legacy: unknown old number / number / project → 404', (await api('GET', '/api/tickets/6')).status === 404 && (await api('GET', PT('alpha', 4))).status === 404 && (await api('GET', PT('nope', 1))).status === 404 && (await api('GET', '/api/projects/nope/tickets')).status === 404);
  289. const oldCmt = await api('POST', '/api/tickets/4/comments', { text: 'via the old number' });
  290. check('legacy: POST /api/tickets/4/comments (old number, gate token) → 201, lands on alpha #3, author = the token\'s user', oldCmt.status === 201 && oldCmt.json.event.author === 'gate' && oldCmt.json.ticket.href === '/projects/alpha/3' && oldCmt.json.ticket.oldNumber === 4 && oldCmt.json.event.seq === 2 && oldCmt.json.event.number === 3 && oldCmt.json.event.project === 'alpha', J(oldCmt.json));
  291. const newCmt = await api('POST', PT('alpha', 3) + '/comments', { text: 'via the project number' });
  292. check('legacy: POST /api/projects/alpha/tickets/3/comments → 201 on the same ticket', newCmt.status === 201 && newCmt.json.ticket.id === oldCmt.json.ticket.id && newCmt.json.ticket.events === 3);
  293. const newSt = await api('POST', PT('beta.example.org', 2) + '/state', { state: 'on-hold', text: 'parked' });
  294. check('legacy: POST /api/projects/beta.example.org/tickets/2/state → 201', newSt.status === 201 && newSt.json.ticket.state === 'pending' && (await api('GET', '/api/tickets/5')).json.ticket.state === 'pending', J(newSt.json));
  295. const a4 = await api('POST', '/api/projects/alpha/tickets', { subject: 'New alpha after the migration' });
  296. check('numbers: POST /api/projects/alpha/tickets → alpha #4, no old number', a4.status === 201 && a4.json.ticket.number === 4 && a4.json.ticket.href === '/projects/alpha/4' && !('oldNumber' in a4.json.ticket) && a4.json.ticket.hasOld === false, J(a4.json));
  297. for (const t of ['gamma', 'antcolony', 'tickets.worldapi.org', 'ident.worldapi.org', 'gitoria.worldapi.org']) await api('POST', '/api/projects', { title: t, slug: t });
  298. const g1 = await api('POST', '/api/tickets', { project: 'gamma', subject: 'first of a new project' });
  299. check('numbers: a new project starts at 1 (POST /api/tickets)', g1.status === 201 && g1.json.ticket.project === 'gamma' && g1.json.ticket.number === 1 && g1.json.ticket.href === '/projects/gamma/1', J(g1.json));
  300. const g2 = await api('POST', '/api/tickets', { project: 'gamma', subject: 'second of gamma' });
  301. check('numbers: … and counts on (gamma #2)', g2.json.ticket.number === 2 && (await api('GET', PT('gamma', 2))).json.ticket.subject === 'second of gamma');
  302. check('numbers: the per-project POST refuses a project field (strict)', (await api('POST', '/api/projects/alpha/tickets', { subject: 's', project: 'alpha' })).json.field === 'project');
  303. const redir = await fetch(BASE + '/tickets/3', { redirect: 'manual' });
  304. check('legacy: GET /tickets/3 (old page URL) → 301 Location /projects/alpha/2', redir.status === 301 && redir.headers.get('location') === '/projects/alpha/2', redir.status + ' ' + redir.headers.get('location'));
  305. check('legacy: GET /tickets/99 → 404', (await fetch(BASE + '/tickets/99', { redirect: 'manual' })).status === 404);
  306. const LEGACY = (await api('GET', '/api/tickets')).json.tickets.length; // 5 migrated + alpha #4 + gamma #1, #2
  307. // ---- import: the five AntColony ticket files, twice ----------------------------------
  308. const imp0 = runImport(null);
  309. check('import: without TICKETS_TOKEN every file is refused (401), nothing written', /import: 0 new, 0 already there, 5 failed/.test(imp0) && /FAIL .*: 401/.test(imp0) && (await api('GET', '/api/tickets')).json.tickets.length === LEGACY, imp0);
  310. const imp1 = runImport();
  311. check('import: first run creates all five', /import: 5 new, 0 already there, 0 failed/.test(imp1), imp1);
  312. check('import: numbered per project (antcolony #1, #2)', /NEW 0004-scheduler\.md → antcolony #1/.test(imp1) && /NEW 0005-agent\.md → antcolony #2/.test(imp1) && /NEW 0001-tickets-app\.md → tickets\.worldapi\.org #1/.test(imp1), imp1);
  313. const imp2 = runImport();
  314. check('import: second run is idempotent (0 new, 5 already there)', /import: 0 new, 5 already there, 0 failed/.test(imp2), imp2);
  315. let list = await api('GET', '/api/tickets');
  316. const TOTAL = LEGACY + 5;
  317. check('api: GET /api/tickets lists five more after two imports', list.status === 200 && list.json.tickets.length === TOTAL && list.json.tickets.filter(t => t.source).length === 5, J(list).slice(0, 300));
  318. let one = await api('GET', PT('tickets.worldapi.org', 1));
  319. check('api: tickets.worldapi.org #1 is the first file (project, subject, summary, a created event)',
  320. one.status === 200 && one.json.ticket.project === 'tickets.worldapi.org' && one.json.ticket.subject === 'Build the World Ticket System in Hybriel'
  321. && one.json.ticket.summary.startsWith('Everyone can write everyone a ticket. This is the only state store of AntColony, so it comes first. Minimal scope:')
  322. && one.json.events.length === 1 && one.json.events[0].kind === 'created' && one.json.events[0].text === 'imported from 0001-tickets-app.md' && one.json.events[0].author === 'gate', J(one.json).slice(0, 400));
  323. check('api: antcolony #1 is the scheduler file', (await api('GET', PT('antcolony', 1))).json.ticket.source === '0004-scheduler.md');
  324. check('api: unknown ticket is a 404', (await api('GET', '/api/tickets/99')).status === 404 && (await api('GET', PT('antcolony', 99))).status === 404);
  325. check('api: a ticket without subject is refused (400)', (await api('POST', '/api/tickets', { project: 'x' })).status === 400);
  326. check('api: an unknown state is refused (400)', (await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'nope' })).status === 400);
  327. check('api: an empty comment is refused (400)', (await api('POST', PT('ident.worldapi.org', 1) + '/comments', { text: ' ' })).status === 400);
  328. check('api: filter by project', (await api('GET', '/api/tickets?project=antcolony')).json.tickets.length === 2 && (await api('GET', '/api/projects/antcolony/tickets')).json.tickets.length === 2);
  329. check('api: filter by project + state on the per-project list', (await api('GET', '/api/projects/alpha/tickets?state=in-progress')).json.tickets.map(t => t.number).join() === '1');
  330. const projects = await api('GET', '/api/projects');
  331. const imported = (await api('GET', '/api/tickets')).json.tickets.filter(t => t.source);
  332. check('import: hard-wrapped lines are joined (no line break left in the five)', imported.length === 5 && imported.every(t => !t.summary.includes('\n')));
  333. check('api: projects (creation order) and states', J(projects.json.projects) === J(['alpha', 'beta.example.org', 'gamma', 'antcolony', 'tickets.worldapi.org', 'ident.worldapi.org', 'gitoria.worldapi.org']) && projects.json.states.length === 7, J(projects.json));
  334. // ---- the API is strict (ticket #10): every refused body writes NOTHING ----------------
  335. const rawPost = async (path, raw, auth = 'Bearer ' + TOKEN) => {
  336. const r = await fetch(BASE + path, { method: 'POST', headers: { 'content-type': 'application/json', ...(auth ? { authorization: auth } : {}) }, body: raw });
  337. let json = null; try { json = await r.clone().json(); } catch {}
  338. return { status: r.status, json, text: json ? '' : await r.text() };
  339. };
  340. const strictCases = [
  341. // [path, raw body, expected status, expected `field` (null = none), error must include]
  342. ['/api/tickets', J({ project: 'x', subject: 's', bogus: '1' }), 400, 'bogus', "unknown field 'bogus'"],
  343. ['/api/tickets', J({ project: 'x', subject: 's', Subject: 't' }), 400, 'Subject', "unknown field 'Subject'"],
  344. ['/api/tickets', J({ project: 'x' }), 400, 'subject', "field 'subject' is required"],
  345. ['/api/tickets', J({ subject: 's' }), 400, 'project', "field 'project' is required"],
  346. ['/api/tickets', J({ project: 'x', subject: ' ' }), 400, 'subject', "field 'subject' must not be empty"],
  347. ['/api/tickets', J({ project: '', subject: 's' }), 400, 'project', "field 'project' must not be empty"],
  348. ['/api/tickets', J({ project: 'x', subject: 5 }), 400, 'subject', "field 'subject' must be a string"],
  349. ['/api/tickets', J({ project: 'x', subject: 's', summary: null }), 400, 'summary', "field 'summary' must be a string"],
  350. ['/api/tickets', J({ project: 'a b', subject: 's' }), 400, 'project', 'no such project'],
  351. // ticket #7: NO free author any more — the author is the token's user; a body naming one is refused
  352. ['/api/tickets', J({ project: 'x', subject: 's', author: ['a'] }), 400, 'author', "no field 'author' any more"],
  353. ['/api/tickets', J({ project: 'x', subject: 's', author: 'gate' }), 400, 'author', "no field 'author' any more: the author is the user of your API token"],
  354. ['/api/tickets', J({ project: 'x', subject: 's', summary: 'y', author: ' ' }), 400, 'author', "no field 'author' any more"],
  355. ['/api/tickets/2/comments', J({ text: 'x', author: 'creator' }), 400, 'author', "no field 'author' any more"],
  356. ['/api/tickets/2/comments', J({ text: 'x', author: '' }), 400, 'author', "no field 'author' any more"],
  357. ['/api/tickets/2/state', J({ state: 'in progress', author: 'creator' }), 400, 'author', "no field 'author' any more"],
  358. ['/api/tickets/2/state', J({ state: 'confirmed', text: 'n', author: ' ' }), 400, 'author', "no field 'author' any more"],
  359. ['/api/tickets', J([1, 2]), 400, '', 'must be a JSON object'],
  360. ['/api/tickets', J('text'), 400, '', 'must be a JSON object'],
  361. ['/api/tickets', '', 400, '', 'must be a JSON object'],
  362. ['/api/tickets/2/comments', J({ text: 'hi', state: 'open' }), 400, 'state', "unknown field 'state'"],
  363. ['/api/tickets/2/comments', J({}), 400, 'text', "field 'text' is required"],
  364. ['/api/tickets/2/comments', J({ text: ' ' }), 400, 'text', "field 'text' must not be empty"],
  365. ['/api/tickets/2/comments', J({ text: { a: 1 } }), 400, 'text', "field 'text' must be a string"],
  366. ['/api/tickets/2/comments', J({ text: true }), 400, 'text', "field 'text' must be a string"],
  367. ['/api/tickets/2/comments', J({ text: 'x', author: 7 }), 400, 'author', "no field 'author' any more"],
  368. ['/api/tickets/2/state', J({ state: 'open', note: 'x' }), 400, 'note', "unknown field 'note'"],
  369. ['/api/tickets/2/state', J({ text: 'x' }), 400, 'state', "field 'state' is required"],
  370. ['/api/tickets/2/state', J({ state: '' }), 400, 'state', "field 'state' must not be empty"],
  371. ['/api/tickets/2/state', J({ state: 3 }), 400, 'state', "field 'state' must be a string"],
  372. ['/api/tickets/2/state', J({ state: 'nope' }), 400, 'state', 'unknown state'],
  373. ['/api/tickets/2/state', J({ state: 'open' }), 400, 'state', 'already open'],
  374. ['/api/tickets/99/comments', J({ text: 'x' }), 404, null, 'no such ticket'],
  375. // ticket #38: the slug is the project name, so a new name must be URL-safe; the per-project form is as strict
  376. ['/api/tickets', J({ project: 'a#b', subject: 's' }), 400, 'project', 'no such project'],
  377. ['/api/tickets', J({ project: 'ä', subject: 's' }), 400, 'project', 'no such project'],
  378. ['/api/projects/beta.example.org/tickets/1/comments', J({ text: 'x', author: 'a' }), 400, 'author', "no field 'author' any more"],
  379. ['/api/projects/beta.example.org/tickets/1/comments', J({ text: 'x', bogus: '1' }), 400, 'bogus', "unknown field 'bogus'"],
  380. ['/api/projects/beta.example.org/tickets/1/state', J({ state: 'open' }), 400, 'state', 'already open'],
  381. ['/api/projects/beta.example.org/tickets/1/state', J({ state: 5 }), 400, 'state', "field 'state' must be a string"],
  382. ['/api/projects/beta.example.org/tickets', J({ subject: 's', author: 'a' }), 400, 'author', "no field 'author' any more"],
  383. ['/api/projects/beta.example.org/tickets/9/comments', J({ text: 'x' }), 404, null, 'no such ticket'],
  384. ['/api/projects/beta.example.org/tickets/x/comments', J({ text: 'x' }), 404, null, 'no such ticket'],
  385. ];
  386. for (const [path, raw, want, field, says] of strictCases) {
  387. const r = await rawPost(path, raw);
  388. const ok = r.status === want && r.json && typeof r.json.error === 'string' && r.json.error.includes(says) && (field === null || r.json.field === field);
  389. check(`strict: POST ${path} ${raw || '(empty)'} → ${want}${field ? ' naming ' + field : ''}`, ok, r.status + ' ' + J(r.json) + r.text);
  390. }
  391. // invalid JSON (ticket #15): refused by jsoncheck.hl BEFORE JSON.parse → 400 { error },
  392. // no source path (workaround for hybriel #12: an uncaught JSON.parse answered 500 + api.hl:31:9)
  393. const badJsonCases = [
  394. ['/api/tickets', '{"project":"x",', 15], ['/api/tickets', '{bad', 1], ['/api/tickets', "{'project':'x'}", 1],
  395. ['/api/tickets', '{"project":"x","subject":"s","summary":"a",}', 43], ['/api/tickets', '[1 2]', 3],
  396. ['/api/tickets/2/comments', '{"text":"x","n":"a"} trailing', 21], ['/api/tickets/2/comments', '{"text":"\\x","n":"a"}', 10],
  397. ['/api/tickets/2/state', '{"state":01}', 10], ['/api/tickets/2/state', '['.repeat(70), 64], ['/api/tickets/2/state', ' ', 3],
  398. // LONE \u surrogate escapes: hl's JSON.parse still refuses them (would be a 500) → refused by the check
  399. // (a valid pair parses since hybriel#15, mission 036: see the two checks below)
  400. ['/api/tickets/2/comments', '{"text":"\\ud800"}', 10], ['/api/tickets/2/comments', '{"text":"\\udc00"}', 10],
  401. ['/api/tickets/2/comments', '{"text":"\\ud83dx"}', 10], ['/api/tickets/2/comments', '{"text":"\\ud83d\\u0041"}', 10],
  402. ];
  403. for (const [path, raw, at] of badJsonCases) {
  404. const r = await rawPost(path, raw);
  405. const ok = r.status === 400 && r.json && J(Object.keys(r.json)) === J(['error']) && r.json.error === 'invalid JSON at character ' + at && !/\.hl|\//.test(r.json.error);
  406. check(`strict: invalid JSON POST ${path} ${raw.length > 40 ? raw.slice(0, 40) + '…' : raw} → 400 at ${at}, no source path`, ok, r.status + ' ' + J(r.json) + r.text);
  407. }
  408. // valid but unusual JSON is NOT mistaken for invalid (escapes, unicode, numbers, nesting) → normal field errors
  409. const oddValid = await rawPost('/api/tickets/2/comments', '{ "text" : "q\\"b\\\\s\\/\\u00e9\\n", "n": [-1.5e+3, 0, true, null, {"x":[]}] }');
  410. check('strict: valid unusual JSON passes the check (→ unknown field n, not invalid JSON)', oddValid.status === 400 && oddValid.json && oddValid.json.field === 'n', oddValid.status + ' ' + J(oddValid.json) + oddValid.text);
  411. const pairOnly = await rawPost('/api/tickets/2/comments', '{"text":"\\ud83d\\ude00","n":"a"}');
  412. check('strict: a valid \\u surrogate pair passes the check (hybriel#15 → unknown field n, not invalid JSON)', pairOnly.status === 400 && pairOnly.json && pairOnly.json.field === 'n', pairOnly.status + ' ' + J(pairOnly.json) + pairOnly.text);
  413. const escaped = await rawPost('/api/tickets/3/comments', '{"text":"q\\"b\\\\s\\/\\u00e9 \u{1F600} end \\ud83d\\ude00"}');
  414. check('strict: a comment with JSON escapes (\\" \\\\ \\/ \\u00e9 \\ud83d\\ude00) and a raw emoji is stored decoded (201)', escaped.status === 201 && escaped.json.event.text === 'q"b\\s/\u00e9 \u{1F600} end \u{1F600}', escaped.status + ' ' + J(escaped.json) + escaped.text);
  415. const afterStrict = await api('GET', '/api/tickets');
  416. check('strict: the refused requests wrote nothing (same ticket count, old #2 = beta.example.org #1 has one event)', afterStrict.json.tickets.length === TOTAL && (await api('GET', '/api/tickets/2')).json.events.length === 1 && (await api('GET', PT('beta.example.org', 1))).json.events.length === 1, J(afterStrict.json.tickets.map(t => t.ref)));
  417. check('strict: GET /api/inbox refuses POST (405)', (await rawPost('/api/inbox', '{}')).status === 405);
  418. // ---- ticket #7: API writes need a valid token — 401 BEFORE the body is looked at ----------
  419. const before401 = J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events]));
  420. const authCases = [
  421. // [path, raw body, Authorization header (null = none), label]
  422. ['/api/tickets', J({ project: 'x', subject: 's' }), null, 'no token'],
  423. ['/api/projects/alpha/tickets', J({ subject: 's' }), null, 'no token'],
  424. ['/api/tickets/2/comments', J({ text: 'x' }), null, 'no token'],
  425. ['/api/projects/alpha/tickets/1/comments', J({ text: 'x' }), null, 'no token'],
  426. ['/api/tickets/2/state', J({ state: 'in progress' }), null, 'no token'],
  427. ['/api/projects/alpha/tickets/1/state', J({ state: 'on hold' }), null, 'no token'],
  428. ['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer tkt_' + '0'.repeat(48), 'an unknown token'],
  429. ['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ' + TOKEN + '0', 'a token with one character more'],
  430. ['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ' + TOKEN.slice(0, -1), 'a token with one character less'],
  431. ['/api/tickets/2/comments', J({ text: 'x' }), TOKEN, 'the token without "Bearer"'],
  432. ['/api/tickets/2/comments', J({ text: 'x' }), 'Basic ' + TOKEN, 'Basic instead of Bearer'],
  433. ['/api/tickets/2/comments', J({ text: 'x' }), 'Bearer ', 'an empty Bearer'],
  434. ['/api/tickets/2/comments', '{bad', null, 'no token + invalid JSON (auth first)'],
  435. ['/api/tickets/2/comments', J({ text: 'x', author: 'creator' }), null, 'no token + an author field (auth first)'],
  436. ];
  437. for (const [path, raw, auth, label] of authCases) {
  438. const r = await rawPost(path, raw, auth);
  439. check(`auth: POST ${path} with ${label} → 401`, r.status === 401 && r.json && /Authorization: Bearer/.test(r.json.error), r.status + ' ' + J(r.json) + r.text);
  440. }
  441. check('auth: the refused writes wrote nothing', J((await api('GET', '/api/tickets')).json.tickets.map(t => [t.ref, t.events])) === before401);
  442. const anonRead = await fetch(BASE + '/api/tickets/1');
  443. check('auth: reading needs no token (GET without Authorization → 200)', anonRead.status === 200);
  444. // ---- local time (ticket #10): Europe/Vienna, storage stays epoch ms -----------------------
  445. const vienna = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Vienna', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
  446. const lt = spawnSync(BIN, ['tests/localtime.hl'], { cwd: APP, encoding: 'utf8', timeout: 30000 });
  447. const ltLines = (lt.stdout || '').trim().split('\n').filter(Boolean);
  448. const ltBad = ltLines.filter(l => { const [ms, ...rest] = l.split(' '); return vienna.format(new Date(Number(ms))) !== rest.join(' '); });
  449. check('time: localtime.hl matches Intl Europe/Vienna on DST edges 2000–2100', ltLines.length === 11 && ltBad.length === 0, J(ltBad) + (lt.stderr || ''));
  450. const t2 = (await api('GET', PT('ident.worldapi.org', 1))).json;
  451. check('time: API rows render updatedMs / createdMs in Vienna time', t2.ticket.updated === vienna.format(new Date(t2.ticket.updatedMs)) && t2.events[0].when === vienna.format(new Date(t2.events[0].createdMs)), J([t2.ticket.updated, t2.ticket.updatedMs, t2.events[0].when]));
  452. // ---- two viewers ------------------------------------------------------------------
  453. const range = (v, dflt) => (v || dflt).split('-').map(Number);
  454. A = await launchBrowser({ debugPortRange: range(process.env.TICKETS_GATE_CHROME_A, '8620-8629') });
  455. B = await launchBrowser({ debugPortRange: range(process.env.TICKETS_GATE_CHROME_B, '8630-8639') });
  456. const a = patient(await A.newPage());
  457. const b = patient(await B.newPage());
  458. // SSR of the list
  459. await a.goto(BASE + '/');
  460. await a.waitForSelector('#tickets li');
  461. await connected(a, 'A connected');
  462. check('list: SSR shows every ticket (migrated + new + imported)', (await texts(a, '#tickets li')).length === TOTAL, J(await texts(a, '#tickets li')));
  463. check('list: every row has a state badge, a #ref link and its project', await a.evaluate(`Array.from(document.querySelectorAll('#tickets li')).every(li => li.querySelector('ticket-state') && /^#\\d+/.test(li.querySelector('a.subject').textContent) && li.querySelector('ticket-meta a'))`));
  464. check('list: every ticket link is /projects/<project>/<number> of its row', await a.evaluate(`Array.from(document.querySelectorAll('#tickets li')).every(li => li.querySelector('a.subject').getAttribute('href') === '/projects/' + li.querySelector('ticket-meta a').textContent + '/' + li.querySelector('ticket-ref').textContent.slice(1))`));
  465. check('list: the project filter offers all seven projects', (await texts(a, '#projectfilter a')).length === 8, J(await texts(a, '#projectfilter a')));
  466. check('list: palette — accent is purple #c586c0, danger #f44747', await a.evaluate(`(() => { const cs = getComputedStyle(document.documentElement); return cs.getPropertyValue('--color-accent').trim() === '#c586c0' && getComputedStyle(document.querySelector('.brand')).color === 'rgb(197, 134, 192)' && getComputedStyle(document.body).backgroundColor === 'rgb(25, 30, 35)'; })()`), await a.evaluate(`getComputedStyle(document.querySelector('.brand')).color + ' ' + getComputedStyle(document.body).backgroundColor`));
  467. check('list: danger token resolves to #f44747', await a.evaluate(`(() => { const s = document.createElement('span'); s.style.color = 'var(--color-danger)'; document.body.append(s); const c = getComputedStyle(s).color; s.remove(); return c; })()`) === 'rgb(244, 71, 71)');
  468. // filters by real clicks (client-side navigation)
  469. await clickNav(a, '#projectfilter a[href="/project/antcolony"]', 'location.pathname === "/project/antcolony" && document.querySelectorAll("#tickets li").length === 2', 'project filter');
  470. check('filter: project antcolony shows its two tickets', J(await texts(a, '#tickets ticket-meta a')) === J(['antcolony', 'antcolony']), J(await texts(a, '#tickets ticket-meta a')));
  471. check('filter: the chosen project is marked selected', (await a.text('#projectfilter a.selected')) === 'antcolony');
  472. await clickNav(a, '#statefilter a[href="/project/antcolony/state/progress"]', 'location.pathname === "/project/antcolony/state/progress" && !!document.querySelector("#empty")', 'state filter');
  473. check('filter: project + state "progress" is empty (nothing in progress yet)', (await texts(a, '#tickets li')).length === 0);
  474. await clickNav(a, '#statefilter a[href="/project/antcolony"]', 'document.querySelectorAll("#tickets li").length === 2', 'state filter cleared');
  475. check('filter: clearing the state keeps the project', (await a.evaluate('location.pathname')) === '/project/antcolony');
  476. await a.goto(BASE + '/state/open');
  477. await a.waitForSelector('#tickets li');
  478. const OPEN = (await api('GET', '/api/tickets?state=open')).json.tickets.length;
  479. check('filter: /state/open by URL (SSR) shows every open ticket', (await texts(a, '#tickets li')).length === OPEN, OPEN);
  480. // ---- a migrated ticket in the browser: the old URL redirects, a real click opens the new one
  481. await a.goto(BASE + '/tickets/4');
  482. await a.waitForSelector('#subject');
  483. check('legacy: the old URL /tickets/4 lands on /projects/alpha/3 (redirect in the browser)', (await a.evaluate('location.pathname')) === '/projects/alpha/3' && (await a.text('#subject')) === 'Legacy alpha three' && (await a.text('#ref')) === '#3', await a.evaluate('location.href'));
  484. check('legacy: the page says "formerly #4"', (await a.text('#oldref')) === 'formerly #4', await a.text('#oldref'));
  485. check('legacy: its history holds the created event and both API comments, in order', J(await texts(a, '#events li event-text')) === J(['via the old number', 'via the project number']) && (await texts(a, '#events li')).length === 3, J(await texts(a, '#events li')));
  486. await a.goto(BASE + '/project/alpha');
  487. await a.waitForSelector('#tickets li');
  488. await connected(a, 'A on alpha');
  489. await clickNav(a, '#tickets a.subject[href="/projects/alpha/1"]', 'location.pathname === "/projects/alpha/1" && !!document.querySelector("#events li")', 'click alpha #1');
  490. check('legacy: a real click on a list row opens /projects/alpha/1 (old #1, history in order)', (await a.text('#subject')) === 'Legacy alpha one' && (await a.text('#oldref')) === 'formerly #1' && J(await texts(a, '#events li event-text')) === J(['legacy comment on one', 'started']), J(await texts(a, '#events li')));
  491. await a.goto(BASE + '/projects/gamma/1');
  492. await a.waitForSelector('#subject');
  493. check('numbers: a new ticket has no "formerly"', (await a.text('#ref')) === '#1' && !(await a.evaluate('!!document.querySelector("#oldref")')));
  494. await a.goto(BASE + '/projects/alpha/99');
  495. await a.waitForSelector('#gone');
  496. check('numbers: an unknown number shows "no such ticket"', (await a.text('#gone')).includes('no such ticket'));
  497. await a.goto(BASE + '/projects/gamma');
  498. await a.waitForSelector('#tickets li');
  499. check('numbers: /projects/<slug> lists that project', (await texts(a, '#tickets li')).length === 2 && (await a.text('#heading')) === 'gamma');
  500. // ---- old events keep their authors (ticket #7) --------------------------------------------
  501. await a.goto(BASE + '/projects/alpha/1');
  502. await a.waitForSelector('#events li');
  503. check('legacy: old events show their stored authors (creator, worker, worker)', J(await texts(a, '#events event-head strong')) === J(['creator', 'worker', 'worker']), J(await texts(a, '#events event-head strong')));
  504. // ---- signed out: reading only (ticket #7) --------------------------------------------------
  505. const IDENT1 = '/projects/ident.worldapi.org/1';
  506. await a.goto(BASE + IDENT1);
  507. await a.waitForSelector('#events li');
  508. await connected(a, 'A on ident #1 (signed out)');
  509. check('signed out: the ticket page shows the history, a login hint and NO write forms', (await texts(a, '#events li')).length === 1 && !!(await a.evaluate('!!document.querySelector("#loginhint")')) && !(await a.evaluate('!!document.querySelector("#commentform") || !!document.querySelector("#stateform")')));
  510. check('signed out: top right the selector ("choose ident") and "Log in with ident"', await a.evaluate(`(() => { const s = document.querySelector('#selector'); const r = s && s.shadowRoot; const btn = document.querySelector('#loginbutton'); const hb = document.querySelector('application-header').getBoundingClientRect(); const sb = s.getBoundingClientRect(); return !!r && /choose/.test(r.querySelector('#choose').textContent) && !s.hasAttribute('logged-in') && !!btn && sb.right > hb.right - 400; })()`));
  511. check('signed out: the login button goes to ident /login with the app key and the callback', (await a.evaluate('document.querySelector("#loginbutton").getAttribute("href")')) === `${ident.base}/login?key=${APPKEY.key}&return=${encodeURIComponent(BASE + '/login/callback')}`, await a.evaluate('document.querySelector("#loginbutton").getAttribute("href")'));
  512. check('signed out: the selector is restyled to tickets\' accent (purple)', await a.evaluate(sh('getComputedStyle(r.querySelector("#choose")).backgroundColor')) === 'rgb(197, 134, 192)');
  513. let anonCookie = (await a.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];
  514. const anonTry = await temit('commentOn', [(await api('GET', PT('ident.worldapi.org', 1))).json.ticket.id, 'anonymous'], anonCookie);
  515. check('signed out: a web write (the face, with the browser\'s own cookie) is refused', anonTry.value && /log in with ident/.test(anonTry.value.error), anonTry.raw);
  516. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  517. await viewport(a, w, h);
  518. check(`layout ${name} ${w}px: signed-out header (selector + login button) has no horizontal overflow`, await noOverflow(a));
  519. await shot(a, `${name}-signedout`);
  520. }
  521. await viewport(a, 1280, 900);
  522. // ---- A = alice (the creator): sign in to ident, then the SELECTOR on tickets ---------------
  523. await identSignIn(a, '[email protected]');
  524. await a.goto(BASE + IDENT1);
  525. await a.waitForSelector('#events li');
  526. await connected(a, 'A on ident #1');
  527. await a.evaluate('window.__loginMarker = 1');
  528. await shClick(a, '#choose');
  529. await a.waitFor(sh(`r.querySelectorAll('[part~="identity"]').length > 0`), { label: 'selector list' });
  530. check('selector: lists alice\'s identity', J(await a.evaluate(sh(`[...r.querySelectorAll('[part~="identity"]')].map(b => b.textContent)`))) === J(['Default']));
  531. await shClick(a, '[part~="identity"]', 'Default');
  532. await a.waitForSelector('#nameform', { timeout: 10000 });
  533. check('selector login: no reload, the host set logged-in, the name prompt appears', (await a.evaluate('window.__loginMarker')) === 1 && await a.evaluate('document.querySelector("#selector").hasAttribute("logged-in") && document.querySelector("#selector").loggedIn === true') && /logged in with/.test(await a.evaluate(sh('r.querySelector("#status").textContent'))));
  534. check('selector login: still no write forms before the name', !(await a.evaluate('!!document.querySelector("#commentform")')));
  535. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  536. await viewport(a, w, h);
  537. check(`layout ${name} ${w}px: name prompt has no horizontal overflow`, await noOverflow(a));
  538. await shot(a, `${name}-name`);
  539. }
  540. await viewport(a, 1280, 900);
  541. await a.type('#displayname', 'alice');
  542. await a.click('#namesave');
  543. await a.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#memberhint")', { label: 'A named, no role yet: a member hint, no forms' });
  544. check('name: saved; no role yet → a role hint, no comment form; top right shows "alice"', (await a.evaluate('window.__loginMarker')) === 1 && (await a.text('#whoami')) === 'alice' && !(await a.evaluate('!!document.querySelector("#commentform")')));
  545. // ticket #20: the admin (the gate user, the creator) gives alice a role in every project; the page follows live
  546. const grant = async (name, role) => { for (const slug of (await api('GET', '/api/projects')).json.projects) { const g = await api('POST', `/api/projects/${slug}/members`, { user: name, role }); if (g.status !== 200 && g.status !== 201) throw new Error('grant failed ' + slug + ' ' + J(g)); } };
  547. await grant('alice', 'admin');
  548. await a.goto(BASE + IDENT1);
  549. await a.waitFor('!!document.querySelector("#commentform")', { label: 'A: the forms appear after alice got a role' });
  550. await connected(a, 'A on ident #1 with a role');
  551. await a.evaluate('window.__loginMarker = 1');
  552. check('roles: alice made admin → the comment form appears (page reloaded), no member hint', !(await a.evaluate('!!document.querySelector("#memberhint")')));
  553. for (const [w, h, name] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  554. await viewport(a, w, h);
  555. check(`layout ${name} ${w}px: logged-in header has no horizontal overflow`, await noOverflow(a));
  556. await shot(a, `${name}-loggedin`);
  557. }
  558. await viewport(a, 1280, 900);
  559. const aliceCookie = (await a.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];
  560. const aliceHtml = await (await fetch(BASE + '/', { headers: { cookie: aliceCookie } })).text();
  561. const ALICE_UID = (/\\?"user\\?":\{\\?"id\\?":\\?"([0-9a-z]{12})/.exec(aliceHtml) || [])[1];
  562. check('session: the page knows alice\'s tickets user id, NEVER her identity id', /^[0-9a-z]{12}$/.test(ALICE_UID || '') && !aliceHtml.includes(ALICE_ID), ALICE_UID);
  563. // ---- B = bob: sign in to ident, then the LOGIN BUTTON ---------------------------------------
  564. await identSignIn(b, '[email protected]');
  565. await b.goto(BASE + '/');
  566. await b.waitForSelector('#loginbutton');
  567. await b.click('#loginbutton');
  568. await b.waitForSelector('#chooselist', { timeout: 10000 });
  569. check('button: tickets → ident /signin/<rid> (choose an identity)', (await b.evaluate('location.href')).startsWith(ident.base + '/signin/'));
  570. await connectedIdent(b);
  571. await b.click('#chooselist li:nth-child(1) .choose');
  572. await b.waitFor(`location.href === ${J(BASE + '/')} && !!document.querySelector('#nameform')`, { timeout: 10000, label: 'B back with the name prompt' });
  573. check('button: back on tickets (/login/callback → /), logged in, asked for a name', await b.evaluate('document.querySelector("#selector").classList.contains("in")') && !(await b.evaluate('!!document.querySelector("#newticket")')));
  574. await connected(b, 'B on / after the button login');
  575. await b.type('#displayname', 'bob');
  576. await b.click('#namesave');
  577. await b.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#rolehint")', { label: 'B named' });
  578. check('button: bob named (no role yet: a role hint, no new-ticket form)', (await b.text('#whoami')) === 'bob' && !(await b.evaluate('!!document.querySelector("#newticket")')));
  579. await grant('bob', 'edit');
  580. await b.goto(BASE + '/');
  581. await b.waitFor('!!document.querySelector("#newticket")', { label: 'B: the new-ticket form appears with the role edit' });
  582. await connected(b, 'B on / with a role');
  583. check('roles: bob made edit → the new-ticket form appears', (await b.text('#whoami')) === 'bob');
  584. const bobCookie = (await b.cookies([BASE])).filter(c => c.name === 'hlsid').map(c => 'hlsid=' + c.value)[0];
  585. // ---- the ticket page, two viewers ---------------------------------------------------
  586. await b.goto(BASE + IDENT1);
  587. await b.waitForSelector('#events li');
  588. await connected(b, 'B on ident #1');
  589. await sleep(300);
  590. check('ticket: subject, ref, project, state', (await a.text('#subject')) === 'Rebuild ident in Hybriel: email OTP login' && (await a.text('#ref')) === '#1' && (await a.text('#project')) === 'ident.worldapi.org' && (await a.text('#state')) === 'open', [await a.text('#subject'), await a.text('#ref'), await a.text('#project'), await a.text('#state')].join(' | '));
  591. check('ticket: the imported summary is one paragraph (hard wraps joined)', await a.evaluate(`!document.querySelector('#summary').textContent.includes('\\n') && document.querySelector('#summary').textContent.startsWith('Login for all worldapi apps (tickets, gitoria, …). Only email one-time codes, no passwords. Built on hl:webex;')`), await a.text('#summary'));
  592. check('time: the ticket page shows Vienna time (opened / updated / history)', await a.evaluate(`[document.querySelector('ticket-view header time').textContent, document.querySelector('#updated').textContent, document.querySelector('#events li time').textContent]`).then(v => J(v) === J([t2.ticket.created, t2.ticket.updated, t2.events[0].when]) && t2.ticket.created === vienna.format(new Date(t2.events[0].createdMs))), J(t2.ticket));
  593. check('ticket: history starts with the import event (by the token\'s user "gate")', (await texts(a, '#events li')).length === 1 && (await a.text('#events li')).includes('gate opened the ticket'), await a.text('#events li'));
  594. // A comments (real typing into the form, real click) → B sees it without reload
  595. check('ticket: no author field (ticket #7: the author is the logged-in user)', await a.evaluate('!document.querySelector("#author") && !document.querySelector("input[name=author]")'));
  596. await type(a, '#commenttext', 'first comment from A\nsecond line');
  597. await a.click('#commentsend');
  598. await a.waitFor('document.querySelectorAll("#events li").length === 2', { label: 'A sees its comment' });
  599. check('comment: appended in the writing browser, author = the login (alice)', (await a.text('#events li:last-child')).includes('alice commented') && (await a.text('#events li:last-child event-text')) === 'first comment from A\nsecond line', await a.text('#events li:last-child'));
  600. check('comment: the textarea was cleared', await a.evaluate('document.querySelector("#commenttext").value === ""'));
  601. await b.waitFor('document.querySelectorAll("#events li").length === 2', { label: 'B got the comment live' });
  602. check('live: B sees A\'s comment without a reload', (await b.text('#events li:last-child event-text')) === 'first comment from A\nsecond line');
  603. check('live: B did not reload (no navigation entry)', await b.evaluate('performance.getEntriesByType("navigation").length === 1 && performance.getEntriesByType("navigation")[0].type === "navigate"'));
  604. await b.evaluate('window.__gateMarker = 42'); // survives only if B never reloads
  605. // ---- ticket #20: the new states, the roles, projects with members, the inbox (browser + API) ------------------
  606. // A (alice, admin) moves ident #1 to "review": B follows live; the ticket is assigned to the project's first admin (the gate user)
  607. check('shell: no inbox count yet (nothing assigned to bob)', !(await b.evaluate('!!document.querySelector("#inboxcount")')));
  608. check('state: the choices are the new state names', J((await texts(a, '#newstate option')).sort()) === J(['canceled', 'done', 'pending', 'progress', 'reopened', 'review']), J(await texts(a, '#newstate option')));
  609. await choose(a, '#newstate', 'review');
  610. await type(a, '#statenote', 'please test');
  611. await a.click('#statesend');
  612. await a.waitFor('document.querySelector("#state").textContent === "review"', { label: 'A state changed' });
  613. check('state: A shows the new state and a state event with the note', (await a.text('#events li:nth-last-child(2)')).includes('alice changed the state') && (await a.text('#events li:nth-last-child(2) ticket-state')) === 'review' && (await a.text('#events li:nth-last-child(2) event-text')) === 'please test' && (await a.text('#events li:last-child')).includes('assigned the ticket to gate'), await a.text('#events li:last-child'));
  614. await b.waitFor('document.querySelector("#state").textContent === "review"', { label: 'B state live' });
  615. check('live: B\'s state badge followed, no reload', (await b.evaluate('document.querySelector("#state").className')) === 'review' && (await b.evaluate('window.__gateMarker')) === 42);
  616. const inboxOf = async (tok) => { const r = await fetch(BASE + '/api/inbox', { headers: { authorization: 'Bearer ' + tok } }); let json = null; try { json = await r.json(); } catch {} return { status: r.status, json }; };
  617. const inbox0 = await inboxOf(TOKEN);
  618. check('inbox: the gate user (first admin) has ident #1 in review, only tickets assigned to them', inbox0.status === 200 && inbox0.json.review.some(t => t.subject === 'Rebuild ident in Hybriel: email OTP login') && [...inbox0.json.review, ...inbox0.json.pending].every(t => t.assignee === 'gate'), J(inbox0.json).slice(0, 400));
  619. const oldNames = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'awaiting creator' });
  620. check('states: the old name "awaiting creator" is an alias of review (already there → 400 "already")', oldNames.status === 400 && /already review/.test(oldNames.json.error), J(oldNames));
  621. const aliasBack = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'in progress' });
  622. check('states: "in progress" is an alias of progress (201)', aliasBack.status === 201 && aliasBack.json.ticket.state === 'progress', J(aliasBack).slice(0, 300));
  623. await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'open' });
  624. // roles: bob = use → comment + open ⇄ review, nothing else (web and API)
  625. let aliceTok0 = null;
  626. const bobTok = (await temit('tokenCreate', ['bob gate'], bobCookie)).value.token;
  627. await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'use' });
  628. await b.goto(BASE + IDENT1);
  629. await b.waitForSelector('#stateform');
  630. await connected(b, 'B on ident #1 as use');
  631. check('roles: bob (use) sees only review in the state choices (ticket is open)', J(await texts(b, '#newstate option')) === J(['review']), J(await texts(b, '#newstate option')));
  632. const useDone = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'done' }, bobTok);
  633. const useReview = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'review' }, bobTok);
  634. const useNew = await api('POST', '/api/projects/ident.worldapi.org/tickets', { subject: 'use may not' }, bobTok);
  635. const useSettings = await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'admin' }, bobTok);
  636. const useComment = await api('POST', PT('ident.worldapi.org', 1) + '/comments', { text: 'a use member comments' }, bobTok);
  637. check('roles: use → done 403, review 201, new ticket 403, members 403, comment 201', [useDone.status, useReview.status, useNew.status, useSettings.status, useComment.status].join() === '403,201,403,403,201', J([useDone, useNew, useSettings]).slice(0, 500));
  638. await b.goto(BASE + '/projects/ident.worldapi.org/settings');
  639. await b.waitForSelector('#notadmin');
  640. check('roles: the settings page of a non-admin says so and has no forms', !(await b.evaluate('!!document.querySelector("#detailsform")')));
  641. await api('POST', '/api/projects/ident.worldapi.org/members', { user: 'bob', role: 'edit' });
  642. const editAssign = await api('POST', PT('ident.worldapi.org', 1) + '/assign', { assignee: 'bob' }, bobTok);
  643. check('roles: edit may assign (201) and move to any state', editAssign.status === 201 || editAssign.status === 200, J(editAssign).slice(0, 300));
  644. const editPending = await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'pending' }, bobTok);
  645. check('roles: edit → pending 201; "on hold" is an alias of pending', editPending.status === 201 && editPending.json.ticket.state === 'pending', J(editPending).slice(0, 300));
  646. // the inbox page of bob: pending and review, only what is assigned to him
  647. await b.goto(BASE + '/inbox');
  648. await b.waitForSelector('#pendingbox');
  649. await connected(b, 'B inbox');
  650. check('inbox: bob has ident #1 under pending, review is empty', (await texts(b, '#pending li a.subject')).length === 1 && (await b.text('#pending li a.subject')).includes('Rebuild ident') && !!(await b.evaluate('!!document.querySelector("#reviewempty")')), J(await texts(b, '#pendingbox')));
  651. await api('POST', PT('ident.worldapi.org', 1) + '/state', { state: 'review' }, bobTok);
  652. await b.waitFor('document.querySelectorAll("#review li").length === 1 && document.querySelectorAll("#pending li").length === 0', { label: 'inbox follows the state change live' });
  653. check('inbox: the ticket moved from pending to review live, header count 1', (await b.text('#inboxcount')) === '1');
  654. aliceTok0 = (await temit('tokenCreate', ['alice #20'], aliceCookie)).value.token;
  655. check('inbox: alice (admin, nothing assigned) has an empty inbox', (await inboxOf(aliceTok0)).json.review.length === 0);
  656. // a new project by hand: title → slug proposed, admin can change it; old slugs keep working
  657. await a.goto(BASE + '/new-project');
  658. await a.waitForSelector('#projectform');
  659. await connected(a, 'A on /new-project');
  660. await type(a, '#projecttitle', 'My Project');
  661. await a.waitFor('document.querySelector("#projectslug").value === "my-project"', { label: 'slug proposed' });
  662. check('project: the slug my-project is proposed from the title', (await a.evaluate('document.querySelector("#projectslug").value')) === 'my-project');
  663. await a.click('#projectcreate');
  664. await a.waitForSelector('#created');
  665. const mp = await api('GET', '/api/projects/my-project');
  666. check('project: created; alice is its first admin; it has no tickets', mp.status === 200 && mp.json.project.title === 'My Project' && J(mp.json.members.map(m => [m.name, m.role])) === J([['alice', 'admin']]), J(mp.json).slice(0, 400));
  667. await a.goto(BASE + '/projects/my-project/settings');
  668. await a.waitForSelector('#detailsform');
  669. await connected(a, 'A on settings');
  670. await type(a, '#setslug', 'my-proj');
  671. await type(a, '#setdescription', 'The **first** project.');
  672. await a.click('#detailssave');
  673. await a.waitFor('!!document.querySelector("#savemessage") && document.querySelector("#savemessage").textContent.length > 0', { label: 'saved' });
  674. const mp2 = await api('GET', '/api/projects/my-proj');
  675. const mpOld = await api('GET', '/api/projects/my-project');
  676. check('project: the slug changed, the description is kept, the old slug still resolves', mp2.status === 200 && mp2.json.project.description === 'The **first** project.' && mpOld.status === 200 && mpOld.json.project.slug === 'my-proj', J([mp2.json.project, mpOld.status]).slice(0, 400));
  677. await a.goto(BASE + '/projects/my-project');
  678. await a.waitForSelector('#heading');
  679. check('project: the old address still opens the project page', (await a.text('#heading')) === 'My Project' || (await a.text('#heading')).includes('My Project'), await a.text('#heading'));
  680. // members in the settings page: add bob as use; remove; the last admin cannot go
  681. await a.goto(BASE + '/projects/my-proj/settings');
  682. await a.waitForSelector('#addform');
  683. await connected(a, 'A on settings again');
  684. await type(a, '#addref', 'bob');
  685. await choose(a, '#addrole', 'use');
  686. await a.click('#addsave');
  687. await a.waitFor('document.querySelectorAll("#members li").length === 2', { label: 'bob added' });
  688. check('members: bob added as use in the settings page', J((await api('GET', '/api/projects/my-proj')).json.members.map(m => [m.name, m.role])) === J([['alice', 'admin'], ['bob', 'use']]));
  689. const lastAdmin = await api('POST', '/api/projects/my-proj/members', { user: 'alice', role: 'use' }, aliceTok0);
  690. check('members: the last admin cannot be demoted (400)', lastAdmin.status === 400 && /at least one admin/.test(lastAdmin.json.error), J(lastAdmin));
  691. const badRole = await api('POST', '/api/projects/my-proj/members', { user: 'bob', role: 'boss' }, aliceTok0);
  692. check('members: an unknown role is refused (400)', badRole.status === 400 && badRole.json.field === 'role', J(badRole));
  693. const rm = await api('POST', '/api/projects/my-proj/members/remove', { user: 'bob' }, aliceTok0);
  694. check('members: an admin removes a member (200)', rm.status === 200 && rm.json.members.length === 1, J(rm).slice(0, 300));
  695. // an invite link through ident
  696. const inv = await api('POST', '/api/projects/my-proj/invites', { role: 'use' }, aliceTok0);
  697. check('invite: an admin gets an ident invite link for the role use', inv.status === 201 && /^https?:\/\//.test(inv.json.url || ''), J(inv).slice(0, 400));
  698. // carol opens the link, chooses her identity at ident → ident sends her back to tickets with the invite id → she is a member
  699. const carol = await ident.signIn('[email protected]');
  700. const invPage = await fetch(inv.json.url, { redirect: 'manual' });
  701. const rid = (invPage.headers.get('location') || '').split('/').pop();
  702. const chosen = await ident.emit('chooseIdentity', [rid, carol.identities[0].id], carol.cookie);
  703. const back = chosen.value && chosen.value.url ? await fetch(chosen.value.url.replace(/^https?:\/\/[^/]+/, BASE), { redirect: 'manual' }) : { status: 0, headers: new Headers() };
  704. const carolCookie = (back.headers.get('set-cookie') || '').split(';')[0];
  705. const carolFace = await temit('saveDisplayName', ['carol'], carolCookie);
  706. const mp3 = (await api('GET', '/api/projects/my-proj')).json;
  707. check('invite: carol follows the link, picks an identity at ident, is sent back → member with the role use', invPage.status === 302 && back.status === 302 && J(mp3.members.map(m => [m.name, m.role]).filter(x => x[0] === 'carol' || x[1] === 'use')) === J([['carol', 'use']]), J([invPage.status, chosen.raw.slice(0, 200), back.status, mp3.members]).slice(0, 600));
  708. const invBob = await api('POST', '/api/projects/my-proj/invites', { role: 'use' }, bobTok);
  709. check('invite: a non-admin gets 403', invBob.status === 403, J(invBob).slice(0, 200));
  710. // the new pages fit the screen (phone and desktop), with the project's data on them
  711. for (const [path, sel, name] of [['/projects/my-proj/settings', '#addform', 'settings'], ['/new-project', '#projectform', 'new-project'], ['/projects/my-proj', '#heading', 'project']]) {
  712. await a.goto(BASE + path);
  713. await a.waitForSelector(sel);
  714. await connected(a, 'A on ' + path);
  715. for (const [w, h, dev] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  716. await viewport(a, w, h);
  717. check(`layout ${dev} ${w}px: ${name} page has no horizontal overflow`, await noOverflow(a));
  718. await shot(a, `${dev}-${name}`);
  719. }
  720. }
  721. await viewport(a, 1280, 900);
  722. await b.goto(BASE + '/inbox');
  723. await b.waitForSelector('#pendingbox');
  724. for (const [w, h, dev] of [[390, 844, 'phone'], [1280, 900, 'desktop']]) {
  725. await viewport(b, w, h);
  726. check(`layout ${dev} ${w}px: inbox page has no horizontal overflow`, await noOverflow(b));
  727. }
  728. await viewport(b, 1280, 900);
  729. // ---- the browsers said nothing bad (checked BEFORE ident goes down, ticket #11) ------------------
  730. const problems = [...a.problems(), ...b.problems()].map(m => m.text).filter(t => !/favicon/.test(t));
  731. check('console: no errors or warnings in either browser', problems.length === 0, J(problems).slice(0, 800));
  732. // ---- ident down: a login fails politely, the server keeps serving ----------------------------
  733. // TICKET #11: no page may be loading while ident stops (a tickets page loads ident's
  734. // selector.js → ERR_CONNECTION_REFUSED in the console). Both browsers park on about:blank
  735. // first and stay there; the phase is checked with fetch only, and the console again after.
  736. for (const p of [a, b]) { await p.goto('about:blank'); }
  737. await sleep(300);
  738. const quietBefore = [...a.problems(), ...b.problems()].length;
  739. await ident.stop();
  740. const down = await fetch(BASE + '/login/callback?ident_code=' + 'cd'.repeat(24), { headers: { cookie: GATE_COOKIE }, redirect: 'manual' });
  741. const downText = await down.text();
  742. const downFace = await temit('identLogin', ['cd'.repeat(24)], GATE_COOKIE);
  743. check('ident down: /login/callback → 400 "ident did not answer" (no 500, no source path)', down.status === 400 && /ident did not answer/.test(downText) && !/\.hl/.test(downText), down.status + ' ' + downText.slice(0, 300));
  744. check('ident down: the selector face answers "ident did not answer", the server keeps serving', downFace.value && downFace.value.error === 'ident did not answer' && (await fetch(BASE + '/')).status === 200, downFace.raw);
  745. const lateProblems = [...a.problems(), ...b.problems()].slice(quietBefore).map(m => m.text);
  746. check('console: nothing new while ident was down (the browsers were parked, #11)', lateProblems.length === 0, J(lateProblems).slice(0, 800));
  747. } catch (e) {
  748. check('gate ran to the end', false, e.stack || String(e));
  749. } finally {
  750. for (const br of [A, B]) { if (br) { try { await br.close(); } catch {} } }
  751. await stopTickets();
  752. if (ident) { await ident.stop(); writeFileSync(join(SCRATCH, 'gate-ident.log'), ident.log()); }
  753. exchProxy.close();
  754. writeFileSync(join(SCRATCH, 'gate-server.log'), log);
  755. }
  756. console.log(`\n${passes} passed, ${failures} failed`);
  757. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre