tickets
All repositories: gitoria
19.4 KB
// tests/connect.mjs — THE CONNECT GATE (ticket #21): an app (gitoria) is connected to ONE project. A small ident STUB (only POST /api/exchange: one-time code → short ident id, like ident#23) and its own tickets server on a fresh store; alice (admin of her projects), bob (a member)// and carol (logged in, no role) log in; then over HTTP: the request refusals (return address), the one-time code, the// exchange, the key's reach (its project only; create / comment / state; roles apply to the NAMED person; no// settings / members / other project / relations), the disconnect; and in a real headless Chrome: the connect page// (log-in hint, project choice, a new project, confirm, the link back), the project page's line and its Disconnect.// Ports: TICKETS_CONNECT_PORT (8700), TICKETS_CONNECT_IDENT_PORT (8701), Chrome debug 8703-8709.// node tests/connect.mjsimport { spawn } from 'node:child_process';import { rmSync, mkdirSync, existsSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser } from './cdp.mjs';import http from 'node:http';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const BIN = join(APP, 'bin/hybriel');const PORT = Number(process.env.TICKETS_CONNECT_PORT || 8700);const IDENT_PORT = Number(process.env.TICKETS_CONNECT_IDENT_PORT || 8701);const BASE = `http://127.0.0.1:${PORT}`;const GITORIA = 'http://127.0.0.1:8702'; // the "app": only its URL is used (listed in TICKETS_CONNECT_ORIGINS)const WORK = join(APP, '.scratch/connect-gate');const STORE = join(WORK, 'store');const J = JSON.stringify;const sleep = (ms) => new Promise(r => setTimeout(r, ms));rmSync(WORK, { recursive: true, force: true });mkdirSync(join(STORE, 'mpackdb'), { recursive: true });let passes = 0, failures = 0;const check = (label, ok, detail = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`); if (ok) passes++; else failures++; };let log = '', server = null, ident = null, browser = null;async function main() {// the ident stub: a one-time code → the identity it was minted for (5-character ids, as ident#23)const codes = new Map();let codeN = 0;const mint = (identity) => { const c = (++codeN).toString(16).padStart(8, '0').repeat(3); codes.set(c, identity); return c; };ident = http.createServer((req, res) => {let body = ''; req.on('data', d => body += d);req.on('end', () => {if (req.url === '/api/exchange' && req.method === 'POST') {const b = JSON.parse(body || '{}'); const id = codes.get(b.code); codes.delete(b.code);res.writeHead(id && b.secret === 'sk_stub' ? 200 : 400, { 'content-type': 'application/json' });return res.end(J(id ? { identity: id } : { error: 'unknown code' }));}res.writeHead(404); res.end('');});});await new Promise(ok => ident.listen(IDENT_PORT, '127.0.0.1', ok));ident.base = `http://127.0.0.1:${IDENT_PORT}`;ident.stop = () => new Promise(ok => ident.close(ok));const app = { key: 'pk_stub', secret: 'sk_stub' };const alice0 = { id: 'a2b3c' }, bob0 = { id: 'b4c5d' }, carol0 = { id: 'c6d7e' };server = spawn(BIN, ['project.hl'], { cwd: APP, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, TICKETS_PORT: String(PORT), TICKETS_STORAGE: join(STORE, 'mpackdb'), TICKETS_SESSIONS: join(STORE, 'sessions'),IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TICKETS_PUBLIC_URL: BASE, IDENT_API_KEY: app.key, IDENT_API_SECRET: app.secret, TICKETS_CREATOR_IDENTITY: '', TICKETS_WATCH: '0', HL_HOST: '127.0.0.1', TICKETS_CONNECT_ORIGINS: GITORIA } });server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);for (let i = 0; i < 80; i++) { try { if ((await fetch(BASE + '/')).ok) break; } catch {} await sleep(250); }let emitI = 0;const temit = async (event, payload, cookie) => {const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: J({ t: 'emit', i: ++emitI, event, payload }) });const raw = await r.text(); let j = null; try { j = JSON.parse(raw); } catch {}return j ? j.value : undefined;};const call = async (method, path, body, headers = {}) => {const r = await fetch(BASE + path, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...headers }, body: body ? J(body) : undefined, redirect: 'manual' });let json = null; const t = await r.text(); try { json = JSON.parse(t); } catch {}return { status: r.status, json, text: t, headers: r.headers };};const login = async (who, name) => {const code = mint(who.id);const identity = who.id;const r = await fetch(BASE + '/login/callback?ident_code=' + code, { redirect: 'manual' });const cookie = (r.headers.get('set-cookie') || '').split(';')[0];if (name) await temit('saveDisplayName', [name], cookie);return { cookie, identity };};const alice = await login(alice0, 'alice'), bob = await login(bob0, 'bob'), carol = await login(carol0, 'carol');const tok = async (u) => (await temit('tokenCreate', ['gate'], u.cookie)).token;alice.token = await tok(alice); bob.token = await tok(bob);const auth = (t, extra = {}) => ({ authorization: 'Bearer ' + t, ...extra });// ---- projects: alice admins "gitoria-tickets" and "other"; bob is a member (edit) of gitoria-tickets, carol nothinglet r = await call('POST', '/api/projects', { title: 'Gitoria tickets', slug: 'gt' }, auth(alice.token));check('setup: alice opens project gt', r.status === 201, J(r.json));r = await call('POST', '/api/projects', { title: 'Other', slug: 'other' }, auth(alice.token));check('setup: alice opens project other', r.status === 201, J(r.json));r = await call('POST', '/api/projects/gt/members', { user: bob.identity, role: 'edit' }, auth(alice.token));check('setup: bob is edit in gt', r.status === 200, J(r.json));const bobUse = await call('POST', '/api/projects', { title: 'Bobs', slug: 'bobs' }, auth(bob.token));await call('POST', '/api/projects/bobs/members', { user: alice.identity, role: 'use' }, auth(bob.token));// ---- the requestconst good = `/connect?app=gitoria&label=${encodeURIComponent('anton/repo')}&return=${encodeURIComponent(GITORIA + '/connect/back')}&state=abc123`;r = await call('GET', good);const nonce = (r.headers.get('location') || '').replace('/connect/', '');check('request: a valid request redirects to /connect/<nonce>', r.status === 302 && /^[0-9a-f]{32}$/.test(nonce), r.status + ' ' + r.headers.get('location'));for (const [what, q] of [['a return address on another host', `app=x&return=${encodeURIComponent('https://evil.example.com/x')}`],['a worldapi.org lookalike', `app=x&return=${encodeURIComponent('https://worldapi.org.evil.com/x')}`],['userinfo in the return address', `app=x&return=${encodeURIComponent('https://[email protected]/x')}`],['http on worldapi.org', `app=x&return=${encodeURIComponent('http://gitoria.worldapi.org/x')}`],['no return address', 'app=x'],['no app name', `return=${encodeURIComponent('https://gitoria.worldapi.org/x')}`],]) { r = await call('GET', '/connect?' + q); check('request refused: ' + what, r.status === 400 && !r.headers.get('location'), r.status + ' ' + r.text.slice(0, 200)); }r = await call('GET', `/connect?app=x&return=${encodeURIComponent('https://gitoria.worldapi.org/back?x=1')}`);check('request: a subdomain of worldapi.org is allowed', r.status === 302, String(r.status));// ---- the confirmation (face): only a named user, only an admin project, onceconst page = await call('GET', '/connect/' + nonce);check('page: the connect page names the app, its label and its host', page.status === 200 && page.text.includes('gitoria') && page.text.includes('anton/repo') && page.text.includes('127.0.0.1:8702'), page.text.slice(0, 200));check('page: an unknown request says used up or expired', (await call('GET', '/connect/' + '0'.repeat(32))).text.includes('used up or expired'));let v = await temit('connectConfirm', [nonce, '', 'x'], null);check('confirm: signed out → refused', v && v.error, J(v));const gtId = (await call('GET', '/api/projects/gt')).json.project.id;const otherId = (await call('GET', '/api/projects/other')).json.project.id;v = await temit('connectConfirm', [nonce, gtId, ''], carol.cookie);check('confirm: carol (no role in gt) → refused, request still open', v && v.error && v.error.includes('only an admin'), J(v));v = await temit('connectConfirm', [nonce, gtId, ''], bob.cookie);check('confirm: bob (edit, not admin) → refused', v && v.error && v.error.includes('only an admin'), J(v));v = await temit('connectConfirm', [nonce, gtId, ''], alice.cookie);check('confirm: alice (admin) → a link back with a one-time code and the state', v && v.url && v.url.startsWith(GITORIA + '/connect/back?code=') && v.url.endsWith('&state=abc123'), J(v));const code = v.url.match(/code=([0-9a-f]+)/)[1];v = await temit('connectConfirm', [nonce, gtId, ''], alice.cookie);check('confirm: the same request twice → refused', v && v.error && v.error.includes('used up'), J(v));// ---- the exchanger = await call('POST', '/api/connect/exchange', { code: 'ab'.repeat(24) });check('exchange: a wrong code → 400', r.status === 400, r.status + ' ' + r.text);r = await call('POST', '/api/connect/exchange', { code });const key = r.json && r.json.key;check('exchange: the code gives a key for gt (shown once)', r.status === 200 && /^tktc_[0-9a-f]{48}$/.test(key) && r.json.project === 'gt', r.status + ' ' + r.text);r = await call('POST', '/api/connect/exchange', { code });check('exchange: the code works once only', r.status === 400, r.status + ' ' + r.text);check('exchange: the key is not stored in clear', !(await call('GET', '/api/projects/gt/connections')).text.includes(key));// ---- the key's reachconst named = (who) => auth(key, { 'x-tickets-identity': who.identity });r = await call('POST', '/api/projects/gt/tickets', { subject: 'from the app' }, named(bob));check('key: creates a ticket as the named person (bob, role edit)', r.status === 201 && r.json.ticket.subject === 'from the app', r.status + ' ' + r.text);const num = r.json && r.json.ticket && r.json.ticket.number;r = await call('GET', '/api/projects/gt/tickets/' + num);check('key: the ticket is created BY bob (event author, same ident id)', r.json.events[0].author === 'bob' && r.json.events[0].userId !== '', J(r.json.events && r.json.events[0]));r = await call('POST', '/api/tickets', { project: 'gt', subject: 'via the global route' }, named(alice));check('key: POST /api/tickets with project gt works too (alice)', r.status === 201, r.status + ' ' + r.text);r = await call('POST', `/api/projects/gt/tickets/${num}/comments`, { text: 'mentioned in PR #7' }, named(bob));check('key: posts a comment "mentioned in PR" as bob', r.status === 201 && r.json.event.author === 'bob', r.status + ' ' + r.text);r = await call('POST', `/api/projects/gt/tickets/${num}/state`, { state: 'review', text: 'fixed by commit abc' }, named(bob));check('key: changes the state (fixed by commit → review)', r.status === 201 && r.json.ticket.state === 'review', r.status + ' ' + r.text);r = await call('POST', '/api/projects/gt/tickets', { subject: 'no person' }, auth(key));check('key: without X-Tickets-Identity → 403 naming the header', r.status === 403 && r.text.includes('X-Tickets-Identity'), r.status + ' ' + r.text);r = await call('POST', '/api/projects/gt/tickets', { subject: 'carol' }, named(carol));check('key: a person with no role in the project → 403 (roles apply)', r.status === 403, r.status + ' ' + r.text);r = await call('POST', '/api/projects/gt/tickets', { subject: 'nobody' }, auth(key, { 'x-tickets-identity': 'zzzzz' }));check('key: an identity that never logged in → 403', r.status === 403, r.status + ' ' + r.text);r = await call('POST', '/api/projects/other/tickets', { subject: 'wrong project' }, named(alice));check('key: another project → 403', r.status === 403 && r.text.includes('another project'), r.status + ' ' + r.text);r = await call('POST', '/api/tickets', { project: 'other', subject: 'wrong project' }, named(alice));check('key: POST /api/tickets naming another project → 403', r.status === 403, r.status + ' ' + r.text);const oth = (await call('POST', '/api/projects/other/tickets', { subject: 'in other' }, auth(alice.token))).json.ticket.number;r = await call('POST', `/api/projects/other/tickets/${oth}/comments`, { text: 'token still works' }, auth(alice.token));check('token: a user token still comments and changes state as before', r.status === 201 && (await call('POST', `/api/projects/other/tickets/${oth}/state`, { state: 'progress' }, auth(alice.token))).status === 201, r.status + ' ' + r.text);r = await call('POST', `/api/projects/other/tickets/${oth}/comments`, { text: 'x' }, named(alice));check('key: a comment on a ticket of another project → 403', r.status === 403, r.status + ' ' + r.text);r = await call('POST', `/api/tickets/${(await call('GET', '/api/projects/other/tickets/' + oth)).json.ticket.id}/state`, { state: 'review' }, named(alice));check('key: a state change by ticket id in another project → 403', r.status === 403, r.status + ' ' + r.text);for (const [what, m, p, b] of [['project settings', 'POST', '/api/projects/gt', { title: 'hacked' }],['members', 'POST', '/api/projects/gt/members', { user: carol.identity, role: 'admin' }],['invites', 'POST', '/api/projects/gt/invites', { role: 'use' }],['a new project', 'POST', '/api/projects', { title: 'nope' }],['editing a ticket', 'POST', `/api/projects/gt/tickets/${num}/edit`, { subject: 'x' }],['assigning', 'POST', `/api/projects/gt/tickets/${num}/assign`, { assignee: 'bob' }],['the parent link', 'POST', `/api/projects/gt/tickets/${num}/parent`, { parent: '' }],['disconnecting', 'POST', '/api/projects/gt/connections/remove', { id: 'x' }],['the inbox', 'GET', '/api/inbox', null],]) { r = await call(m, p, b, named(alice)); check('key cannot use: ' + what, r.status === 401, r.status + ' ' + r.text.slice(0, 120)); }check('key: the project settings did not change', (await call('GET', '/api/projects/gt')).json.project.title === 'Gitoria tickets');r = await call('POST', '/api/projects/gt/tickets', { subject: 'bad key' }, auth('tktc_' + '0'.repeat(48), { 'x-tickets-identity': alice.identity }));check('key: a wrong key → 401', r.status === 401, String(r.status));r = await call('GET', '/api/projects/gt/connections');check('list: gt shows the connection "gitoria: anton/repo" (public)', r.json.connections.length === 1 && r.json.connections[0].text === 'gitoria: anton/repo' && r.json.connections[0].by === 'alice', J(r.json));check('list: the project other shows none', (await call('GET', '/api/projects/other/connections')).json.connections.length === 0);// ---- disconnect through the API: only an admin; the key dies at onceconst cid = r.json.connections[0].id;r = await call('POST', '/api/projects/gt/connections/remove', { id: cid }, auth(bob.token));check('disconnect: bob (edit) → 403', r.status === 403, r.status + ' ' + r.text);r = await call('POST', '/api/projects/gt/connections/remove', { id: cid }, auth(alice.token));check('disconnect: alice (admin) → connection gone', r.status === 200 && r.json.connections.length === 0, r.status + ' ' + r.text);r = await call('POST', '/api/projects/gt/tickets', { subject: 'after' }, named(alice));check('disconnect: the key is dead at once → 401', r.status === 401, String(r.status));// ---- the browser: log in, connect a new project, see it on the project page, disconnectbrowser = await launchBrowser({ debugPortRange: [8703, 8709] });const p = await browser.newPage();const nonce2 = ((await call('GET', good.replace('abc123', 'st2'))).headers.get('location') || '').replace('/connect/', '');await p.goto(BASE + '/connect/' + nonce2);await p.waitForSelector('#connecttitle');check('browser: signed out → the login hint, no form', (await p.evaluate('!!document.querySelector("#loginhint") && !document.querySelector("#connectform")')));const cookieName = alice.cookie.split('=')[0];await p.send('Network.setCookie', { name: cookieName, value: alice.cookie.slice(cookieName.length + 1), url: BASE });await p.goto(BASE + '/connect/' + nonce2);await p.waitForSelector('#connectform');check('browser: alice logged in → the project choice lists her admin projects and "A new project"',J(await p.evaluate('Array.from(document.querySelectorAll("#connectproject option")).map(o => o.textContent.trim())')) === J(['Gitoria tickets', 'Other', 'A new project']));check('browser: it says which app and host asks', (await p.evaluate('document.querySelector("#connectwho").textContent')).includes('anton/repo'));await p.evaluate('(() => { const s = document.querySelector("#connectproject"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()');await p.waitForSelector('#connecttitleinput');await p.type('#connecttitleinput', 'Repo tickets');await p.click('#connectconfirm');await p.waitForSelector('#connectcontinue');const back = await p.evaluate('document.querySelector("#connectcontinue").href');check('browser: after confirming, the page links back to the app with code and state', back.startsWith(GITORIA + '/connect/back?code=') && back.endsWith('&state=st2'), back);r = await call('POST', '/api/connect/exchange', { code: back.match(/code=([0-9a-f]+)/)[1] });const key2 = r.json.key;check('browser: the exchange gives a key for the NEW project', r.status === 200 && r.json.project === 'repo-tickets', r.status + ' ' + r.text);await p.goto(BASE + '/projects/repo-tickets');await p.waitForSelector('#connections');check('browser: the project page shows "Connected to gitoria: anton/repo" and an admin sees Disconnect',(await p.evaluate('document.querySelector("#connections").textContent')).includes('Connected to gitoria: anton/repo') && await p.evaluate('!!document.querySelector("#connections .disconnect")'));await p.click('#connections .disconnect');await p.waitFor('!document.querySelector("#connections")', { label: 'connection line gone' });r = await call('POST', '/api/projects/repo-tickets/tickets', { subject: 'x' }, auth(key2, { 'x-tickets-identity': alice.identity }));check('browser: after Disconnect the key is dead (401)', r.status === 401, String(r.status));// a reader (not logged in) sees the line but no buttonr = await call('POST', '/api/projects/gt/connections/remove', { id: 'x' }, auth(alice.token));const p2 = await browser.newPage();await p2.goto(BASE + '/projects/other');await p2.waitForSelector('#heading');check('browser: a project without a connection shows no line', await p2.evaluate('!document.querySelector("#connections")'));check('server log: no absorbed errors from the connect code', !/error absorbed/.test(log), log.split('\n').filter(l => /error absorbed/.test(l)).slice(0, 3).join(' | '));}try { await main(); } catch (e) { failures++; console.log('FAIL gate crashed — ' + (e && e.stack || e)); console.log(log.slice(-2500)); }finally {try { if (browser) await browser.close(); } catch {}try { if (server) server.kill('SIGTERM'); } catch {}try { if (ident) await ident.stop(); } catch {}console.log(`\n${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);}
Branches
- mainmain branch