ident
All repositories: gitoria
15.5 KB
// tests/invites.mjs — THE GATE OF THE INVITE SERVICE (ticket ident#22): an app invites people// by link through ident. Real headless Chrome (tests/cdp.mjs, --disable-gpu) opens the links;// the API parts run over HTTP. Own servers only, NEVER the dev server, NEVER real mail:// ident :8700 (IDENT_MAIL_SINK, a "day" of 3 s so an invite expires within the run),// test app :8701 (the app of the invites, against ident :8700).// Own storage: .scratch/invites-gate/ (wiped at start). Chrome debug ports 8706-8709.//// Run: node tests/invites.mjs (exit 0 = all passed)import { spawn } from 'node:child_process';import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs';import { dirname, join } from 'node:path';import { fileURLToPath } from 'node:url';import { launchBrowser, sleep } from './cdp.mjs';const APP = join(dirname(fileURLToPath(import.meta.url)), '..');const G = join(APP, '.scratch/invites-gate');const ID = 'http://127.0.0.1:8700', TA = 'http://127.0.0.1:8701';let passed = 0, failed = 0;const check = (name, ok, detail = '') => {if (ok) { passed++; console.log(' ok ' + name); }else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }};const procs = [];function start(cwd, env, log) {const p = spawn(join(APP, 'bin/hybriel'), ['project.hl'], { cwd, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });let out = '';p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { out += d; });p.on('exit', () => writeFileSync(join(G, log), out));procs.push(p);}async function up(url) {for (let i = 0; i < 80; i++) { try { await fetch(url + '/', { redirect: 'manual' }); return; } catch {} await sleep(250); }throw new Error('server did not come up: ' + url);}const stopAll = () => { for (const p of procs) { try { p.kill(); } catch {} } };rmSync(G, { recursive: true, force: true });mkdirSync(join(G, 'ident'), { recursive: true });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');await up(ID);const lastCode = (email) => {const f = join(G, 'mail.txt');const lines = existsSync(f) ? readFileSync(f, 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' ')) : [];return lines.length ? lines[lines.length - 1].split(' ')[1] : null;};const inviteMails = () => { const f = join(G, 'mail.txt.invites'); return existsSync(f) ? readFileSync(f, 'utf8').trim().split('\n').filter(Boolean) : []; };let emitI = 0;async function emit(event, payload, cookie) {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 }) });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { cookie: (r.headers.get('set-cookie') || '').split(';')[0], value: j && j.value, raw: t };}async function post(path, body, base = ID) {const r = await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: typeof body === 'string' ? body : JSON.stringify(body) });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, j, t };}// the app's owner: an ident account + the app (over the faces)await post('/api/code', { email: '[email protected]' });const own = await emit('verifyCode', ['[email protected]', lastCode('[email protected]'), 'UTC']);const created = (await emit('appCreate', [{ name: 'Demo tickets', origins: [TA] }], own.cookie)).value;const KEY = created.app.apiKey, SECRET = created.secret;writeFileSync(join(G, 'testapp.json'), JSON.stringify({ key: KEY, secret: SECRET, users: {}, count: 0, sessions: {} }));start(join(APP, 'testapp'), { TESTAPP_PORT: '8701', TESTAPP_URL: TA, IDENT_URL: ID, TESTAPP_STORE: join(G, 'testapp.json') }, 'testapp.log');await up(TA);const A = { key: KEY, secret: SECRET };const mk = (extra = {}) => post('/api/invites', { ...A, project: 'Website', role: 'editor', return: TA + '/callback', ...extra });const idOf = (invId) => post('/api/invites/get', { ...A, id: invId });let br;try {// ---- the API: negatives -----------------------------------------------------------------console.log('API');let r = await post('/api/invites', { ...A, key: 'pk_' + '0'.repeat(32), project: 'p', role: 'r', return: TA + '/callback' });check('unknown key → 401', r.status === 401, r.t);r = await post('/api/invites', { ...A, secret: 'sk_' + '0'.repeat(48), project: 'p', role: 'r', return: TA + '/callback' });check('wrong secret → 401', r.status === 401, r.t);r = await mk({ bogus: 1 });check('unknown field → 400 naming it', r.status === 400 && /unknown field: bogus/.test(r.j.error), r.t);r = await post('/api/invites', { ...A, project: 'p', role: 'r' });check('missing return → 400 naming it', r.status === 400 && /missing field: return/.test(r.j.error), r.t);r = await mk({ return: 'http://evil.example/cb' });check('return outside the app origins → 400', r.status === 400 && /not one of the origins/.test(r.j.error), r.t);r = await mk({ uses: 0 });check('uses 0 → 400', r.status === 400 && /uses/.test(r.j.error), r.t);r = await mk({ days: 1.5 });check('days 1.5 → 400', r.status === 400 && /days/.test(r.j.error), r.t);r = await mk({ email: 'nope' });check('bad email → 400', r.status === 400 && /email/.test(r.j.error), r.t);r = await mk({ role: '' });check('empty role → 400', r.status === 400 && /role/.test(r.j.error), r.t);r = await fetch(ID + '/api/invites');check('GET on the create call → 405', r.status === 405);r = await fetch(ID + '/invite/' + '0'.repeat(32), { redirect: 'manual' });check('unknown link → 404 error page', r.status === 404 && /not valid/.test(await r.text()));// ---- 1. a new address: email → code → back in the app ------------------------------------console.log('new person, private window');r = await mk({ days: 90 });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);const inv1 = r.j;br = await launchBrowser({ debugPortRange: [8706, 8709] });const p = await br.newPage();await p.send('Emulation.setTimezoneOverride', { timezoneId: 'Europe/Vienna' });await p.goto(inv1.url);await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'hydrated' });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'));await p.type('#email', '[email protected]');await p.click('#sendcode');await p.waitFor('!!document.querySelector("#code")', { label: 'code form' });await p.type('#code', lastCode('[email protected]'));await p.click('#verify');await p.waitFor('!!document.querySelector("#skip")', { label: 'welcome form' });await p.click('#skip');await p.waitFor('!!document.querySelector("#chooselist .choose")', { label: 'choose list' });await p.click('#chooselist .choose');await p.waitFor('!!document.querySelector("#invite")', { label: 'back in the app', timeout: 15000 });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'));check('the invite carries its project and role', (await p.text('#inviteproject')) === 'Website' && (await p.text('#inviterole')) === 'editor');const g = await idOf(inv1.id);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);check('the answer never contains the link or an email', !/newbie|invite\//.test(g.t));// ---- 2. the same link again --------------------------------------------------------------console.log('used, revoked, expired');const again = await fetch(inv1.url, { redirect: 'manual' });const againText = await again.text();check('the same link again → "already used"', again.status === 410 && /already used/.test(againText), again.status + againText.slice(0, 80));r = await mk();const inv2 = r.j;r = await post('/api/invites/revoke', { ...A, id: inv2.id });check('revoke an open invite → revoked', r.status === 200 && r.j.invite.state === 'revoked', r.t);const rv = await fetch(inv2.url, { redirect: 'manual' });check('a revoked link → "withdrawn"', rv.status === 410 && /withdrawn/.test(await rv.text()));r = await post('/api/invites/revoke', { ...A, id: inv1.id });check('revoking a used invite is refused (409)', r.status === 409, r.t);r = await mk({ days: 1 });const inv3 = r.j;await sleep(3300);const ex = await fetch(inv3.url, { redirect: 'manual' });check('an expired link → "expired"', ex.status === 410 && /expired/.test(await ex.text()));check('get: state expired', (await idOf(inv3.id)).j.invite.state === 'expired');// ---- 3. already logged in to ident: one click --------------------------------------------console.log('logged in to ident already');r = await mk({ days: 90 });const inv4 = r.j;await p.goto(inv4.url);await p.waitFor('!!document.querySelector("#chooselist .choose")', { label: 'choose list (signed in)' });check('signed in: no email step, only the identity choice', !(await p.evaluate('!!document.querySelector("#email")')));await p.click('#chooselist .choose');await p.waitFor('!!document.querySelector("#invite")', { label: 'back in the app', timeout: 15000 });check('one click → back in the app, invite used', (await p.text('#invitestate')) === 'used');const g4 = await idOf(inv4.id);check('the same identity accepted both invites (one id per app)', g4.j.invite.identities[0] === g.j.invite.identities[0]);// ---- 4. several uses, list ---------------------------------------------------------------console.log('two uses, list');r = await mk({ uses: 2, project: 'Docs', days: 90 });const inv5 = r.j;const accept = async () => {const q = await fetch(inv5.url, { redirect: 'manual' });const rid = (q.headers.get('location') || '').split('/').pop();return { q, rid };};const a1 = await accept();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')));// two different people accept it over the facesconst people = [];for (const e of ['[email protected]', '[email protected]', '[email protected]']) {await post('/api/code', { email: e });const v = await emit('verifyCode', [e, lastCode(e), 'UTC']);people.push({ cookie: v.cookie, ident: v.value.identities[0].id });}const choose = async (who, rid) => (await emit('chooseIdentity', [rid, who.ident], who.cookie)).value;let c1 = await choose(people[0], a1.rid);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));const a2 = await accept();let c2 = await choose(people[1], a2.rid);check('second person accepts', !!c2.url, JSON.stringify(c2));const a3 = await fetch(inv5.url, { redirect: 'manual' });check('third opening: used up → "already used"', a3.status === 410 && /already used/.test(await a3.text()));const g5 = await idOf(inv5.id);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);// a request parked BEFORE the last use, accepted AFTER it, is refused with a clear messager = await mk({ uses: 1, days: 90 });const inv6 = r.j;const early = (await fetch(inv6.url, { redirect: 'manual' })).headers.get('location').split('/').pop();const early2 = (await fetch(inv6.url, { redirect: 'manual' })).headers.get('location').split('/').pop();const ok1 = await choose(people[0], early);const bad = await choose(people[2], early2);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]));r = await post('/api/invites/list', { ...A });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);r = await post('/api/invites/list', { ...A, project: 'Docs' });check('list narrowed to a project', r.j.invites.length === 1 && r.j.invites[0].project === 'Docs');const other = await post('/api/invites/get', { ...A, id: 'nonexistent1' });check('get an unknown id → 404', other.status === 404);// ---- 5. mail through ident's mailer ------------------------------------------------------console.log('mail');r = await mk({ email: '[email protected]', days: 90 });check('create with an address → mailed', r.status === 200 && r.j.mailed === true, r.t);const mails = inviteMails();check('the mail (sink) holds the address and the very link', mails.length === 1 && mails[0] === '[email protected] ' + r.j.url, JSON.stringify(mails));const mailedLink = r.j.url;await mk({ email: '[email protected]', days: 90 }); await mk({ email: '[email protected]', days: 90 });r = await mk({ email: '[email protected]', days: 90 });check('a 4th mail within the day is refused (429), nothing sent', r.status === 429 && inviteMails().length === 3, r.t);const p2 = await br.newPage();await p2.goto(mailedLink);await p2.waitFor('!!document.querySelector("#apprequest")', { label: 'sign-in from the mailed link' });check('the mailed link opens ident sign-in', /invited to\s*Demo tickets/.test(await p2.text('#apprequest')));// ---- 6. the test app page ----------------------------------------------------------------console.log('the app page');await p2.goto(TA + '/invites');await p2.waitFor('!!document.querySelector("#invites")', { label: 'invites page' });check('the app lists its invites with their state', (await p2.evaluate('document.querySelectorAll("#invites li").length')) >= 9);await p2.type('#iproject', 'FromForm', { clear: true });await p2.click('#create');await p2.waitFor('!!document.querySelector("#link")', { label: 'link shown' });const link = await p2.evaluate('document.querySelector("#link").value');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);await p2.click('#invites li:last-child .revoke');await p2.waitFor('/revoked/.test(document.querySelector("#invites li:last-child .state")?.textContent || "")', { label: 'revoked in the list' });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'));r = await post('/api/invites/list', { ...A, project: 'FromForm' });check('ident agrees: revoked', r.j.invites.length === 1 && r.j.invites[0].state === 'revoked', r.t);} catch (e) {failed++; console.log(' FAIL exception — ' + (e && e.stack || e));} finally {if (br) await br.close();stopAll();console.log(`\n${passed} passed, ${failed} failed`);process.exit(failed ? 1 : 0);}
Branches
- mainmain branch