gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit81b15b7b81b15b7bState of 2026-09-27, before the move to gitoriamre81b15b7b/tests/invites.mjs

15.5 KB

  1. // tests/invites.mjs — THE GATE OF THE INVITE SERVICE (ticket ident#22): an app invites people
  2. // by link through ident. Real headless Chrome (tests/cdp.mjs, --disable-gpu) opens the links;
  3. // the API parts run over HTTP. Own servers only, NEVER the dev server, NEVER real mail:
  4. // ident :8700 (IDENT_MAIL_SINK, a "day" of 3 s so an invite expires within the run),
  5. // test app :8701 (the app of the invites, against ident :8700).
  6. // Own storage: .scratch/invites-gate/ (wiped at start). Chrome debug ports 8706-8709.
  7. //
  8. // Run: node tests/invites.mjs (exit 0 = all passed)
  9. import { spawn } from 'node:child_process';
  10. import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs';
  11. import { dirname, join } from 'node:path';
  12. import { fileURLToPath } from 'node:url';
  13. import { launchBrowser, sleep } from './cdp.mjs';
  14. const APP = join(dirname(fileURLToPath(import.meta.url)), '..');
  15. const G = join(APP, '.scratch/invites-gate');
  16. const ID = 'http://127.0.0.1:8700', TA = 'http://127.0.0.1:8701';
  17. let passed = 0, failed = 0;
  18. const check = (name, ok, detail = '') => {
  19. if (ok) { passed++; console.log(' ok ' + name); }
  20. else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
  21. };
  22. const procs = [];
  23. function start(cwd, env, log) {
  24. const p = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
  25. let out = '';
  26. p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { out += d; });
  27. p.on('exit', () => writeFileSync(join(G, log), out));
  28. procs.push(p);
  29. }
  30. async function up(url) {
  31. for (let i = 0; i < 80; i++) { try { await fetch(url + '/', { redirect: 'manual' }); return; } catch {} await sleep(250); }
  32. throw new Error('server did not come up: ' + url);
  33. }
  34. const stopAll = () => { for (const p of procs) { try { p.kill(); } catch {} } };
  35. rmSync(G, { recursive: true, force: true });
  36. mkdirSync(join(G, 'ident'), { recursive: true });
  37. start(APP, { IDENT_PORT: '8700', IDENT_STORAGE: join(G, 'ident'), IDENT_SESSIONS: join(G, 'sess') + '/', IDENT_MAIL_SINK: join(G, 'mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000', IDENT_INVITE_DAY_MS: '3000', IDENT_INVITE_MAIL_LIMIT: '3' }, 'ident.log');
  38. await up(ID);
  39. const lastCode = (email) => {
  40. const f = join(G, 'mail.txt');
  41. const lines = existsSync(f) ? readFileSync(f, 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' ')) : [];
  42. return lines.length ? lines[lines.length - 1].split(' ')[1] : null;
  43. };
  44. const inviteMails = () => { const f = join(G, 'mail.txt.invites'); return existsSync(f) ? readFileSync(f, 'utf8').trim().split('\n').filter(Boolean) : []; };
  45. let emitI = 0;
  46. async function emit(event, payload, cookie) {
  47. const r = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify({ t: 'emit', i: ++emitI, event, payload }) });
  48. const t = await r.text();
  49. let j = null; try { j = JSON.parse(t); } catch {}
  50. return { cookie: (r.headers.get('set-cookie') || '').split(';')[0], value: j && j.value, raw: t };
  51. }
  52. async function post(path, body, base = ID) {
  53. const r = await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: typeof body === 'string' ? body : JSON.stringify(body) });
  54. const t = await r.text();
  55. let j = null; try { j = JSON.parse(t); } catch {}
  56. return { status: r.status, j, t };
  57. }
  58. // the app's owner: an ident account + the app (over the faces)
  59. await post('/api/code', { email: '[email protected]' });
  60. const own = await emit('verifyCode', ['[email protected]', lastCode('[email protected]'), 'UTC']);
  61. const created = (await emit('appCreate', [{ name: 'Demo tickets', origins: [TA] }], own.cookie)).value;
  62. const KEY = created.app.apiKey, SECRET = created.secret;
  63. writeFileSync(join(G, 'testapp.json'), JSON.stringify({ key: KEY, secret: SECRET, users: {}, count: 0, sessions: {} }));
  64. start(join(APP, 'testapp'), { TESTAPP_PORT: '8701', TESTAPP_URL: TA, IDENT_URL: ID, TESTAPP_STORE: join(G, 'testapp.json') }, 'testapp.log');
  65. await up(TA);
  66. const A = { key: KEY, secret: SECRET };
  67. const mk = (extra = {}) => post('/api/invites', { ...A, project: 'Website', role: 'editor', return: TA + '/callback', ...extra });
  68. const idOf = (invId) => post('/api/invites/get', { ...A, id: invId });
  69. let br;
  70. try {
  71. // ---- the API: negatives -----------------------------------------------------------------
  72. console.log('API');
  73. let r = await post('/api/invites', { ...A, key: 'pk_' + '0'.repeat(32), project: 'p', role: 'r', return: TA + '/callback' });
  74. check('unknown key → 401', r.status === 401, r.t);
  75. r = await post('/api/invites', { ...A, secret: 'sk_' + '0'.repeat(48), project: 'p', role: 'r', return: TA + '/callback' });
  76. check('wrong secret → 401', r.status === 401, r.t);
  77. r = await mk({ bogus: 1 });
  78. check('unknown field → 400 naming it', r.status === 400 && /unknown field: bogus/.test(r.j.error), r.t);
  79. r = await post('/api/invites', { ...A, project: 'p', role: 'r' });
  80. check('missing return → 400 naming it', r.status === 400 && /missing field: return/.test(r.j.error), r.t);
  81. r = await mk({ return: 'http://evil.example/cb' });
  82. check('return outside the app origins → 400', r.status === 400 && /not one of the origins/.test(r.j.error), r.t);
  83. r = await mk({ uses: 0 });
  84. check('uses 0 → 400', r.status === 400 && /uses/.test(r.j.error), r.t);
  85. r = await mk({ days: 1.5 });
  86. check('days 1.5 → 400', r.status === 400 && /days/.test(r.j.error), r.t);
  87. r = await mk({ email: 'nope' });
  88. check('bad email → 400', r.status === 400 && /email/.test(r.j.error), r.t);
  89. r = await mk({ role: '' });
  90. check('empty role → 400', r.status === 400 && /role/.test(r.j.error), r.t);
  91. r = await fetch(ID + '/api/invites');
  92. check('GET on the create call → 405', r.status === 405);
  93. r = await fetch(ID + '/invite/' + '0'.repeat(32), { redirect: 'manual' });
  94. check('unknown link → 404 error page', r.status === 404 && /not valid/.test(await r.text()));
  95. // ---- 1. a new address: email → code → back in the app ------------------------------------
  96. console.log('new person, private window');
  97. r = await mk({ days: 90 });
  98. check('create → 200 with link, single use, open', r.status === 200 && /\/invite\/[0-9a-f]{32}$/.test(r.j.url) && r.j.uses === 1 && r.j.state === 'open' && r.j.url.startsWith(ID), r.t);
  99. const inv1 = r.j;
  100. br = await launchBrowser({ debugPortRange: [8706, 8709] });
  101. const p = await br.newPage();
  102. await p.send('Emulation.setTimezoneOverride', { timezoneId: 'Europe/Vienna' });
  103. await p.goto(inv1.url);
  104. await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'hydrated' });
  105. check('link → ident sign-in for the app, worded as an invitation', /You are invited to\s*Demo tickets/.test(await p.text('#apprequest')), await p.text('#apprequest'));
  106. await p.type('#email', '[email protected]');
  107. await p.click('#sendcode');
  108. await p.waitFor('!!document.querySelector("#code")', { label: 'code form' });
  109. await p.type('#code', lastCode('[email protected]'));
  110. await p.click('#verify');
  111. await p.waitFor('!!document.querySelector("#skip")', { label: 'welcome form' });
  112. await p.click('#skip');
  113. await p.waitFor('!!document.querySelector("#chooselist .choose")', { label: 'choose list' });
  114. await p.click('#chooselist .choose');
  115. await p.waitFor('!!document.querySelector("#invite")', { label: 'back in the app', timeout: 15000 });
  116. check('back in the app: invite reports used, accepted by the logged-in identity', (await p.text('#invitestate')) === 'used' && (await p.text('#invitewho')) === 'this identity', await p.text('#invite'));
  117. check('the invite carries its project and role', (await p.text('#inviteproject')) === 'Website' && (await p.text('#inviterole')) === 'editor');
  118. const g = await idOf(inv1.id);
  119. check('get: used 1 of 1, one identity, state used', g.j.invite.state === 'used' && g.j.invite.used === 1 && g.j.invite.identities.length === 1 && /^[2-9a-hj-km-np-z]{5}$/.test(g.j.invite.identities[0]), g.t);
  120. check('the answer never contains the link or an email', !/newbie|invite\//.test(g.t));
  121. // ---- 2. the same link again --------------------------------------------------------------
  122. console.log('used, revoked, expired');
  123. const again = await fetch(inv1.url, { redirect: 'manual' });
  124. const againText = await again.text();
  125. check('the same link again → "already used"', again.status === 410 && /already used/.test(againText), again.status + againText.slice(0, 80));
  126. r = await mk();
  127. const inv2 = r.j;
  128. r = await post('/api/invites/revoke', { ...A, id: inv2.id });
  129. check('revoke an open invite → revoked', r.status === 200 && r.j.invite.state === 'revoked', r.t);
  130. const rv = await fetch(inv2.url, { redirect: 'manual' });
  131. check('a revoked link → "withdrawn"', rv.status === 410 && /withdrawn/.test(await rv.text()));
  132. r = await post('/api/invites/revoke', { ...A, id: inv1.id });
  133. check('revoking a used invite is refused (409)', r.status === 409, r.t);
  134. r = await mk({ days: 1 });
  135. const inv3 = r.j;
  136. await sleep(3300);
  137. const ex = await fetch(inv3.url, { redirect: 'manual' });
  138. check('an expired link → "expired"', ex.status === 410 && /expired/.test(await ex.text()));
  139. check('get: state expired', (await idOf(inv3.id)).j.invite.state === 'expired');
  140. // ---- 3. already logged in to ident: one click --------------------------------------------
  141. console.log('logged in to ident already');
  142. r = await mk({ days: 90 });
  143. const inv4 = r.j;
  144. await p.goto(inv4.url);
  145. await p.waitFor('!!document.querySelector("#chooselist .choose")', { label: 'choose list (signed in)' });
  146. check('signed in: no email step, only the identity choice', !(await p.evaluate('!!document.querySelector("#email")')));
  147. await p.click('#chooselist .choose');
  148. await p.waitFor('!!document.querySelector("#invite")', { label: 'back in the app', timeout: 15000 });
  149. check('one click → back in the app, invite used', (await p.text('#invitestate')) === 'used');
  150. const g4 = await idOf(inv4.id);
  151. check('the same identity accepted both invites (one id per app)', g4.j.invite.identities[0] === g.j.invite.identities[0]);
  152. // ---- 4. several uses, list ---------------------------------------------------------------
  153. console.log('two uses, list');
  154. r = await mk({ uses: 2, project: 'Docs', days: 90 });
  155. const inv5 = r.j;
  156. const accept = async () => {
  157. const q = await fetch(inv5.url, { redirect: 'manual' });
  158. const rid = (q.headers.get('location') || '').split('/').pop();
  159. return { q, rid };
  160. };
  161. const a1 = await accept();
  162. check('open invite → 302 to the sign-in of the app', a1.q.status === 302 && /^\/signin\/[0-9a-f]{32}$/.test(a1.q.headers.get('location')));
  163. // two different people accept it over the faces
  164. const people = [];
  165. for (const e of ['[email protected]', '[email protected]', '[email protected]']) {
  166. await post('/api/code', { email: e });
  167. const v = await emit('verifyCode', [e, lastCode(e), 'UTC']);
  168. people.push({ cookie: v.cookie, ident: v.value.identities[0].id });
  169. }
  170. const choose = async (who, rid) => (await emit('chooseIdentity', [rid, who.ident], who.cookie)).value;
  171. let c1 = await choose(people[0], a1.rid);
  172. check('first person accepts → return URL with ident_code and invite', !!c1.url && c1.url.includes('ident_code=') && c1.url.includes('invite=' + inv5.id), JSON.stringify(c1));
  173. const a2 = await accept();
  174. let c2 = await choose(people[1], a2.rid);
  175. check('second person accepts', !!c2.url, JSON.stringify(c2));
  176. const a3 = await fetch(inv5.url, { redirect: 'manual' });
  177. check('third opening: used up → "already used"', a3.status === 410 && /already used/.test(await a3.text()));
  178. const g5 = await idOf(inv5.id);
  179. check('get: used 2 of 2 with two different identities', g5.j.invite.used === 2 && g5.j.invite.uses === 2 && g5.j.invite.identities[0] !== g5.j.invite.identities[1], g5.t);
  180. // a request parked BEFORE the last use, accepted AFTER it, is refused with a clear message
  181. r = await mk({ uses: 1, days: 90 });
  182. const inv6 = r.j;
  183. const early = (await fetch(inv6.url, { redirect: 'manual' })).headers.get('location').split('/').pop();
  184. const early2 = (await fetch(inv6.url, { redirect: 'manual' })).headers.get('location').split('/').pop();
  185. const ok1 = await choose(people[0], early);
  186. const bad = await choose(people[2], early2);
  187. check('two parked requests, one use: first wins, second told "already used"', !!ok1.url && bad.error && /already used/.test(bad.error), JSON.stringify([ok1, bad]));
  188. r = await post('/api/invites/list', { ...A });
  189. check('list: all invites of the app with states', r.status === 200 && r.j.invites.length === 6 && r.j.invites.some(i => i.state === 'revoked') && r.j.invites.some(i => i.state === 'expired') && r.j.invites.some(i => i.state === 'used'), r.t);
  190. r = await post('/api/invites/list', { ...A, project: 'Docs' });
  191. check('list narrowed to a project', r.j.invites.length === 1 && r.j.invites[0].project === 'Docs');
  192. const other = await post('/api/invites/get', { ...A, id: 'nonexistent1' });
  193. check('get an unknown id → 404', other.status === 404);
  194. // ---- 5. mail through ident's mailer ------------------------------------------------------
  195. console.log('mail');
  196. r = await mk({ email: '[email protected]', days: 90 });
  197. check('create with an address → mailed', r.status === 200 && r.j.mailed === true, r.t);
  198. const mails = inviteMails();
  199. check('the mail (sink) holds the address and the very link', mails.length === 1 && mails[0] === '[email protected] ' + r.j.url, JSON.stringify(mails));
  200. const mailedLink = r.j.url;
  201. await mk({ email: '[email protected]', days: 90 }); await mk({ email: '[email protected]', days: 90 });
  202. r = await mk({ email: '[email protected]', days: 90 });
  203. check('a 4th mail within the day is refused (429), nothing sent', r.status === 429 && inviteMails().length === 3, r.t);
  204. const p2 = await br.newPage();
  205. await p2.goto(mailedLink);
  206. await p2.waitFor('!!document.querySelector("#apprequest")', { label: 'sign-in from the mailed link' });
  207. check('the mailed link opens ident sign-in', /invited to\s*Demo tickets/.test(await p2.text('#apprequest')));
  208. // ---- 6. the test app page ----------------------------------------------------------------
  209. console.log('the app page');
  210. await p2.goto(TA + '/invites');
  211. await p2.waitFor('!!document.querySelector("#invites")', { label: 'invites page' });
  212. check('the app lists its invites with their state', (await p2.evaluate('document.querySelectorAll("#invites li").length')) >= 9);
  213. await p2.type('#iproject', 'FromForm', { clear: true });
  214. await p2.click('#create');
  215. await p2.waitFor('!!document.querySelector("#link")', { label: 'link shown' });
  216. const link = await p2.evaluate('document.querySelector("#link").value');
  217. check('the app shows the link and a share link', /\/invite\/[0-9a-f]{32}$/.test(link) && /^mailto:/.test(await p2.evaluate('document.querySelector("#share").getAttribute("href")')), link);
  218. await p2.click('#invites li:last-child .revoke');
  219. await p2.waitFor('/revoked/.test(document.querySelector("#invites li:last-child .state")?.textContent || "")', { label: 'revoked in the list' });
  220. check('the app revokes it from the list', /revoked/.test(await p2.text('#invites li:last-child .state')), await p2.text('#invites li:last-child'));
  221. r = await post('/api/invites/list', { ...A, project: 'FromForm' });
  222. check('ident agrees: revoked', r.j.invites.length === 1 && r.j.invites[0].state === 'revoked', r.t);
  223. } catch (e) {
  224. failed++; console.log(' FAIL exception — ' + (e && e.stack || e));
  225. } finally {
  226. if (br) await br.close();
  227. stopAll();
  228. console.log(`\n${passed} passed, ${failed} failed`);
  229. process.exit(failed ? 1 : 0);
  230. }

Branches

Latest commits

  • 81b15b7bState of 2026-09-27, before the move to gitoriamre