gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitf8bdcbc2f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmref8bdcbc2/tests/apps.mjs

32.0 KB

  1. // tests/apps.mjs — THE GATE OF PIECE 2 (ticket #25): apps, one id per app, the login
  2. // button, the exchange. Real headless Chrome (tests/cdp.mjs, --disable-gpu) drives:
  3. // register apps in ident → set up the test app → "Log in with ident" → ident login →
  4. // choose identity → back → exchange → the test app shows the id. Plus the negatives.
  5. //
  6. // Own servers only, NEVER the dev server and NEVER real mail:
  7. // ident :8370 (IDENT_MAIL_SINK), ident :8371 (grant TTL 1.5 s, for the expiry case),
  8. // test app A :8372, test app B :8373 (both against ident :8370).
  9. // Own storage: .scratch/apps-gate/ (wiped at start). Chrome debug ports 8670-8679.
  10. // Screenshots: .scratch/apps-*.png (390 and 1280 px) — look at them.
  11. //
  12. // Run: node tests/apps.mjs (exit 0 = all passed)
  13. import { spawn } from 'node:child_process';
  14. import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs';
  15. import { dirname, join } from 'node:path';
  16. import { fileURLToPath } from 'node:url';
  17. import { launchBrowser, sleep } from './cdp.mjs';
  18. const APP = join(dirname(fileURLToPath(import.meta.url)), '..');
  19. const G = join(APP, '.scratch/apps-gate');
  20. const SHOTS = join(APP, '.scratch');
  21. const ID = 'http://127.0.0.1:8370', IDS = 'http://127.0.0.1:8371';
  22. const TA = 'http://127.0.0.1:8372', TB = 'http://127.0.0.1:8373';
  23. const CHROME_PORTS = [8670, 8679];
  24. let passed = 0, failed = 0;
  25. const check = (name, ok, detail = '') => {
  26. if (ok) { passed++; console.log(' ok ' + name); }
  27. else { failed++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
  28. };
  29. // ---- servers ----------------------------------------------------------------------------
  30. const procs = [];
  31. function start(cwd, env, log) {
  32. const p = spawn(join(APP, 'bin/hybriel'), ['project.hl'], {
  33. cwd, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env },
  34. stdio: ['ignore', 'pipe', 'pipe'],
  35. });
  36. let out = '';
  37. p.stdout.on('data', d => { out += d; }); p.stderr.on('data', d => { out += d; });
  38. p.on('exit', () => writeFileSync(join(G, log), out));
  39. procs.push({ p, log, out: () => out });
  40. return p;
  41. }
  42. async function up(url) {
  43. for (let i = 0; i < 80; i++) { try { await fetch(url + '/', { redirect: 'manual' }); return; } catch {} await sleep(250); }
  44. throw new Error('server did not come up: ' + url);
  45. }
  46. function stopAll() { for (const { p } of procs) { try { p.kill(); } catch {} } }
  47. rmSync(G, { recursive: true, force: true });
  48. mkdirSync(G, { recursive: true });
  49. const identEnv = (port, dir, extra = {}) => ({
  50. IDENT_PORT: String(port), IDENT_STORAGE: join(G, dir, 'ident'), IDENT_SESSIONS: join(G, dir, 'sess') + '/',
  51. IDENT_MAIL_SINK: join(G, dir, 'mail.txt'), IDENT_IP_LIMIT: '1000', IDENT_IP_DAY_LIMIT: '1000', ...extra,
  52. });
  53. mkdirSync(join(G, 'main'), { recursive: true }); mkdirSync(join(G, 'short'), { recursive: true });
  54. start(APP, identEnv(8370, 'main'), 'ident-main.log');
  55. start(APP, identEnv(8371, 'short', { IDENT_GRANT_TTL_MS: '1500' }), 'ident-short.log');
  56. const testapp = (port, file) => start(join(APP, 'testapp'), {
  57. TESTAPP_PORT: String(port), TESTAPP_URL: 'http://127.0.0.1:' + port, IDENT_URL: ID, TESTAPP_STORE: join(G, file),
  58. }, 'testapp-' + port + '.log');
  59. testapp(8372, 'testapp-a.json'); testapp(8373, 'testapp-b.json');
  60. await Promise.all([up(ID), up(IDS), up(TA), up(TB)]);
  61. // ---- helpers ----------------------------------------------------------------------------
  62. const lastCode = (dir, email) => {
  63. const lines = readFileSync(join(G, dir, 'mail.txt'), 'utf8').trim().split('\n').filter(l => l.startsWith(email + ' '));
  64. return lines.length ? lines[lines.length - 1].split(' ')[1] : null;
  65. };
  66. let emitI = 0;
  67. // hybriel#16 (mission 036): hl:web itself refuses an emit with one argument too many — the ack is
  68. // ok:false "… the `session` parameter is filled by the server, never by the peer"; the face never runs.
  69. 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; } };
  70. async function emit(base, event, payload, cookie) {
  71. const r = await fetch(base + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify({ t: 'emit', i: ++emitI, event, payload }) });
  72. const t = await r.text();
  73. let j = null; try { j = JSON.parse(t); } catch {}
  74. return { status: r.status, cookie: (r.headers.get('set-cookie') || '').split(';')[0], value: j && j.value, raw: t };
  75. }
  76. async function exchange(base, body) {
  77. const r = await fetch(base + '/api/exchange', { method: 'POST', headers: { 'content-type': 'application/json' }, body: typeof body === 'string' ? body : JSON.stringify(body) });
  78. const t = await r.text();
  79. let j = null; try { j = JSON.parse(t); } catch {}
  80. return { status: r.status, j, t };
  81. }
  82. // log in over the emit API (for the non-browser parts): answers the cookie; the account's
  83. // identities (oldest first; ids are mpackdb UUIDs since mission 009) land in idsOf[cookie]
  84. const idsOf = {};
  85. // (mission 010: the code is requested with POST /api/code — no longer a face; the session
  86. // cookie comes from the verifyCode frame, which mints it)
  87. async function apiLogin(base, dir, email) {
  88. const q = await fetch(base + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }) });
  89. if (q.status !== 200) throw new Error('code request failed: ' + q.status + ' ' + await q.text());
  90. const v = await emit(base, 'verifyCode', [email, lastCode(dir, email), 'UTC']);
  91. if (!v.value || !v.value.account || !v.cookie) throw new Error('api login failed: ' + v.raw);
  92. idsOf[v.cookie] = v.value.identities.map(i => i.id);
  93. return v.cookie;
  94. }
  95. // the ids a signed-in page renders in its buttons' value attributes (SSR), in order, once each
  96. async function ssrIds(base, path, cookie) {
  97. const t = await (await fetch(base + path, { headers: { cookie } })).text();
  98. return [...new Set([...t.matchAll(/value="([0-9a-z]{12})"/g)].map(m => m[1]))];
  99. }
  100. // a login button press + choice over HTTP: answers the one-time code
  101. async function codeFor(base, cookie, key, returnUrl, identity) {
  102. const l = await fetch(base + '/login?key=' + key + '&return=' + encodeURIComponent(returnUrl), { redirect: 'manual' });
  103. const rid = (l.headers.get('location') || '').split('/').pop();
  104. const c = await emit(base, 'chooseIdentity', [rid, identity], cookie);
  105. if (!c.value || !c.value.url) throw new Error('choose failed: ' + c.raw);
  106. return new URL(c.value.url).searchParams.get('ident_code');
  107. }
  108. const pages = [];
  109. async function ready(p) { await p.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'hydrated' }); }
  110. async function viewport(p, w) {
  111. await p.send('Emulation.setDeviceMetricsOverride', { width: w, height: w < 500 ? 844 : 900, deviceScaleFactor: 1, mobile: w < 500 });
  112. }
  113. async function shot(p, name) {
  114. for (const w of [390, 1280]) {
  115. await viewport(p, w);
  116. await sleep(150);
  117. const over = await p.evaluate('document.documentElement.scrollWidth > window.innerWidth');
  118. check(`${name} @${w}px: no horizontal overflow`, !over);
  119. const { data } = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  120. writeFileSync(join(SHOTS, `apps-${name}-${w}.png`), Buffer.from(data, 'base64'));
  121. }
  122. await viewport(p, 1280);
  123. }
  124. // confirm() answers: the next dialog is accepted (true) or dismissed (false)
  125. function onDialogs(p) {
  126. p.dialogAnswers = [];
  127. p.dialogs = [];
  128. p.conn.onEvent((msg) => {
  129. if (msg.sessionId !== p.sessionId || msg.method !== 'Page.javascriptDialogOpening') return;
  130. p.dialogs.push(msg.params.message);
  131. const accept = p.dialogAnswers.length ? p.dialogAnswers.shift() : false;
  132. p.send('Page.handleJavaScriptDialog', { accept }).catch(() => {});
  133. });
  134. }
  135. const here = (p) => p.evaluate('location.href');
  136. const txt = (p, sel) => p.evaluate(`(document.querySelector(${JSON.stringify(sel)}) || {}).textContent || ''`);
  137. async function browserLogin(p, email) {
  138. await p.waitForSelector('#email');
  139. await ready(p);
  140. await p.type('#email', email);
  141. await p.click('#sendcode');
  142. // ident#20: a sent code navigates to the code page (/code, /signin/<rid>/code)
  143. await p.waitFor('/\\/code$/.test(location.pathname) && !!document.querySelector("#code")', { label: 'code page' });
  144. await ready(p);
  145. await p.type('#code', lastCode('main', email));
  146. await p.click('#verify');
  147. }
  148. // ident#20: a REAL reload (Page.reload), then hydrated
  149. async function reload(p) {
  150. p._loaded = false;
  151. await p.send('Page.reload', { ignoreCache: false });
  152. const deadline = Date.now() + 15000;
  153. while (!p._loaded && Date.now() < deadline) await sleep(25);
  154. if (!p._loaded) throw new Error('reload: load event never fired');
  155. await ready(p);
  156. }
  157. let browser1, browser2;
  158. try {
  159. browser1 = await launchBrowser({ debugPortRange: CHROME_PORTS });
  160. browser2 = await launchBrowser({ debugPortRange: CHROME_PORTS });
  161. const p = await browser1.newPage(); pages.push(p); onDialogs(p);
  162. await viewport(p, 1280);
  163. // ==== 1. sign in to ident, register two apps ==========================================
  164. console.log('# apps: register, list, secret shown once');
  165. const ALICE = '[email protected]';
  166. await p.goto(ID + '/apps');
  167. check('signed out /apps asks to sign in', /sign in/i.test(await txt(p, '#signinfirst')));
  168. await p.goto(ID + '/');
  169. await browserLogin(p, ALICE);
  170. await p.waitForSelector('#skip');
  171. await p.click('#skip');
  172. await p.waitFor('!document.querySelector("#identityform")');
  173. await p.goto(ID + '/apps');
  174. await p.waitForSelector('#newapp'); await ready(p);
  175. check('no apps yet', /No apps yet/.test(await txt(p, '#noapps')));
  176. const register = async (name, origins) => {
  177. await p.click('#newapp');
  178. await p.waitForSelector('#appform');
  179. await p.type('#fappname', name);
  180. await p.type('#forigins', origins);
  181. await p.click('#saveapp');
  182. await p.waitFor('!!document.querySelector("#secret") || /\\S/.test(document.querySelector("#message").textContent)');
  183. };
  184. // a bad origin first: refused, named
  185. await register('Bad', 'http://127.0.0.1:8372/callback');
  186. check('origin with a path refused', /no path/.test(await txt(p, '#message')), await txt(p, '#message'));
  187. await p.click('#cancelapp');
  188. await register('Test app A', 'http://127.0.0.1:8372');
  189. const secretA = (await txt(p, '#secret')).trim();
  190. check('secret shown after register (sk_ + 48 hex)', /^sk_[0-9a-f]{48}$/.test(secretA), secretA);
  191. const keyA = (await txt(p, '#apps li:nth-child(1) .apikey')).trim();
  192. check('API key listed (pk_ + 32 hex)', /^pk_[0-9a-f]{32}$/.test(keyA), keyA);
  193. // hybriel#121 (mission 048): a face taking the session syncs the session-derived list back — a row added on top would show twice
  194. check('first app listed ONCE (no session-sync double row)', (await p.evaluate('document.querySelectorAll("#apps li").length')) === 1, await txt(p, '#apps'));
  195. await shot(p, 'secret');
  196. await p.click('#secretdone');
  197. await p.waitFor('!document.querySelector("#secretbox")');
  198. await p.goto(ID + '/apps'); await ready(p);
  199. check('secret not shown again after reload', !(await p.evaluate('document.body.textContent.includes(' + JSON.stringify(secretA) + ')')));
  200. await register('Test app B', 'http://127.0.0.1:8373, http://localhost:8373');
  201. const secretB = (await txt(p, '#secret')).trim();
  202. const keyB = (await txt(p, '#apps li:nth-child(2) .apikey')).trim();
  203. check('second app: own key and secret', keyB !== keyA && secretB !== secretA && /^pk_/.test(keyB));
  204. check('second app: two rows, each app ONCE', (await p.evaluate('[...document.querySelectorAll("#apps li .apikey")].map(e => e.textContent.trim()).join(" ")')) === keyA + ' ' + keyB, await txt(p, '#apps'));
  205. check('origins listed', (await txt(p, '#apps li:nth-child(2)')).includes('http://127.0.0.1:8373 http://localhost:8373'));
  206. await p.click('#secretdone');
  207. await shot(p, 'list');
  208. // edit: rename B and keep one origin
  209. await p.click('#apps li:nth-child(2) .edit');
  210. await p.waitForSelector('#appform');
  211. check('edit form filled', (await p.evaluate('document.querySelector("#forigins").value')) === 'http://127.0.0.1:8373 http://localhost:8373');
  212. await p.type('#fappname', 'Test app B2', { clear: true });
  213. await p.type('#forigins', 'http://127.0.0.1:8373', { clear: true });
  214. await p.click('#saveapp');
  215. await p.waitFor('/App saved/.test(document.querySelector("#notice").textContent)');
  216. const bRow = await txt(p, '#apps li:nth-child(2)');
  217. check('edit saved (name, origins, key unchanged)', bRow.includes('Test app B2') && !bRow.includes('localhost') && bRow.includes(keyB), bRow);
  218. // ==== 2. the test apps are set up with key + secret ===================================
  219. console.log('# test app setup');
  220. const setupApp = async (base, key, secret) => {
  221. await p.goto(base + '/');
  222. await p.type('#key', key, { clear: true });
  223. await p.type('#secret', secret, { clear: true });
  224. await p.evaluate('window.__old = 1');
  225. await p.click('#savesetup');
  226. await p.waitFor('!window.__old && document.readyState === "complete" && (!!document.querySelector("#login") || !!document.querySelector("#result"))', { label: 'setup saved' });
  227. check('test app ' + base + ' saved key + secret', (await txt(p, '#setupkey')) === key, await txt(p, 'body'));
  228. };
  229. await setupApp(TA, keyA, secretA);
  230. check('test app A set up', (await txt(p, '#setupkey')) === keyA);
  231. await setupApp(TB, keyB, secretB);
  232. // ==== 3. the login button, signed in already, one identity ============================
  233. console.log('# login button: signed in, one identity');
  234. const pressLogin = async (base) => {
  235. await p.goto(base + '/');
  236. await p.click('#login');
  237. await p.waitFor('location.pathname.startsWith("/signin/") && !!document.querySelector("#apprequest")', { label: 'ident sign-in page' });
  238. await ready(p);
  239. };
  240. const chooseAndReturn = async (nth, base) => {
  241. await p.waitForSelector('#chooselist');
  242. await p.click(`#chooselist li:nth-child(${nth}) .choose`);
  243. await p.waitFor(`location.href.startsWith(${JSON.stringify(base + '/callback')}) && !!document.querySelector("#result")`, { label: 'back at the app' });
  244. return { identity: (await txt(p, '#identity')).trim(), state: await txt(p, '#userstate'), answer: await txt(p, '#answer'), result: await txt(p, '#result') };
  245. };
  246. await pressLogin(TA);
  247. check('ident names the app and its origin', (await txt(p, '#appname')) === 'Test app A' && (await txt(p, '#apporigin')) === 'http://127.0.0.1:8372');
  248. check('one identity: still shown, one choice', (await p.evaluate('document.querySelectorAll("#chooselist li").length')) === 1 && (await txt(p, '#chooselist li')).includes('Default'));
  249. check('identities management not shown on the app page', !(await p.evaluate('!!document.querySelector("#identitiessection")')));
  250. await shot(p, 'choose-one');
  251. const a1 = await chooseAndReturn(1, TA);
  252. check('back at test app A with an identity id', /^[2-9a-hj-km-np-z]{5}$/.test(a1.identity), JSON.stringify(a1));
  253. check('the app got ONLY the id (no email, no names)', a1.answer === JSON.stringify({ identity: a1.identity }), a1.answer);
  254. check('test app: new user', a1.state === 'new user');
  255. await shot(p, 'testapp-result');
  256. await pressLogin(TA);
  257. const a2 = await chooseAndReturn(1, TA);
  258. check('same identity, same app → same id', a2.identity === a1.identity && a2.state === 'welcome back', JSON.stringify(a2));
  259. await pressLogin(TB);
  260. const b1 = await chooseAndReturn(1, TB);
  261. check('same identity, second app → the SAME id (ident#23)', b1.identity === a1.identity, JSON.stringify([a1.identity, b1.identity]));
  262. // ==== 4. two identities: the choice matters ============================================
  263. console.log('# two identities');
  264. await p.goto(ID + '/'); await ready(p);
  265. await p.click('#newidentity');
  266. await p.waitForSelector('#identityform');
  267. await p.type('#fidentityname', 'Work');
  268. await p.click('#saveidentity');
  269. await p.waitFor('document.querySelectorAll("#identities li").length === 2');
  270. await pressLogin(TA);
  271. check('two identities offered', (await p.evaluate('document.querySelectorAll("#chooselist li").length')) === 2);
  272. await shot(p, 'choose-two');
  273. const aWork = await chooseAndReturn(2, TA);
  274. check('other identity, same app → other id', /^[2-9a-hj-km-np-z]{5}$/.test(aWork.identity) && aWork.identity !== a1.identity && aWork.state === 'new user', JSON.stringify(aWork));
  275. await pressLogin(TA);
  276. const aDef = await chooseAndReturn(1, TA);
  277. check('first identity again → its old id', aDef.identity === a1.identity);
  278. // ==== 5. signed out: ident login first, then the choice ===============================
  279. console.log('# login button: signed out');
  280. const q = await browser2.newPage(); pages.push(q); onDialogs(q);
  281. await viewport(q, 1280);
  282. await q.goto(TB + '/');
  283. await q.click('#login');
  284. await q.waitFor('location.pathname.startsWith("/signin/") && !!document.querySelector("#email")', { label: 'ident login for the app' });
  285. check('signed out: login form under the app banner', (await txt(q, '#apprequest')).includes('Test app B2'));
  286. await shot(q, 'signin-for-app');
  287. const BOB = '[email protected]';
  288. // ident#20 (mission 032): the app login's code step is the page /signin/<rid>/code and
  289. // survives a reload; the code entered after it signs in and the app flow completes
  290. await ready(q);
  291. const ridPath = await q.evaluate('location.pathname');
  292. await q.type('#email', BOB);
  293. await q.click('#sendcode');
  294. await q.waitFor(`location.pathname === ${JSON.stringify(ridPath + '/code')} && !!document.querySelector("#code")`, { label: 'app code page' });
  295. await ready(q);
  296. check('ident#20: app login: "Send me a code" → /signin/<rid>/code with the code form', /^\/signin\/[0-9a-f]{32}\/code$/.test(await q.evaluate('location.pathname')) && (await txt(q, '#sentto strong')) === BOB, await q.evaluate('location.pathname'));
  297. await reload(q);
  298. check('ident#20: app login: RELOAD of /signin/<rid>/code → code form for bob, still under the app banner', (await q.evaluate('location.pathname')) === ridPath + '/code' && (await txt(q, '#sentto strong')) === BOB && !!(await q.evaluate('!!document.querySelector("#code") && !document.querySelector("#email")')) && (await txt(q, '#apprequest')).includes('Test app B2'), await txt(q, 'ident-card'));
  299. await shot(q, 'code-for-app-reloaded');
  300. await q.type('#code', lastCode('main', BOB));
  301. await q.click('#verify');
  302. // a first login: the optional names come first, then the choice
  303. await q.waitForSelector('#welcome');
  304. check('ident#20: app login: signed in after the reload, address bar back on /signin/<rid>', (await q.evaluate('location.pathname')) === ridPath, await q.evaluate('location.pathname'));
  305. check('first login via an app: optional names first', /optional/.test(await txt(q, '#welcome')) && !(await q.evaluate('!!document.querySelector("#chooselist")')));
  306. await q.click('#skip');
  307. await q.waitForSelector('#chooselist');
  308. await q.click('#chooselist li:nth-child(1) .choose');
  309. await q.waitFor(`location.href.startsWith(${JSON.stringify(TB + '/callback')}) && !!document.querySelector("#identity")`);
  310. const bob = (await txt(q, '#identity')).trim();
  311. check('another account → another id', /^[2-9a-hj-km-np-z]{5}$/.test(bob) && bob !== b1.identity && bob !== a1.identity);
  312. // an existing account signs in via the app: no names form, straight to the choice
  313. await q.goto(ID + '/'); await ready(q);
  314. await q.click('#signout');
  315. await q.waitForSelector('#email');
  316. await q.goto(TB + '/');
  317. await q.click('#login');
  318. await q.waitFor('!!document.querySelector("#email")');
  319. // ident#20: "Other address" on the app's code page → back to /signin/<rid>, the email form
  320. await ready(q);
  321. const ridPath2 = await q.evaluate('location.pathname');
  322. await q.type('#email', '[email protected]');
  323. await q.click('#sendcode');
  324. await q.waitFor(`location.pathname === ${JSON.stringify(ridPath2 + '/code')} && !!document.querySelector("#back")`, { label: 'app code page 2' });
  325. await ready(q);
  326. await q.click('#back');
  327. await q.waitFor(`location.pathname === ${JSON.stringify(ridPath2)} && !!document.querySelector("#email")`, { label: 'back to the app sign-in' });
  328. check('ident#20: app login: "Other address" → /signin/<rid> email form under the app banner', (await txt(q, '#apprequest')).includes('Test app B2') && !(await q.evaluate('!!document.querySelector("#code")')));
  329. await reload(q);
  330. check('ident#20: app login: … and a reload stays on the email form', (await q.evaluate('location.pathname')) === ridPath2 && (await q.evaluate('!!document.querySelector("#email") && !document.querySelector("#code")')));
  331. await q.goto(ID + ridPath2 + '/code');
  332. await q.waitForSelector('#email');
  333. check('ident#20: app login: /signin/<rid>/code with nothing pending → back to /signin/<rid>', (await q.evaluate('location.pathname')) === ridPath2);
  334. await browserLogin(q, BOB);
  335. await q.waitForSelector('#chooselist');
  336. check('known account: straight to the choice (no names form)', !(await q.evaluate('!!document.querySelector("#welcome")')));
  337. await q.click('#chooselist li:nth-child(1) .choose');
  338. await q.waitFor(`!!document.querySelector("#identity")`);
  339. check('after an OTP login: same id as before', (await txt(q, '#identity')).trim() === bob);
  340. // ==== 6. the login button refuses: error page, never a redirect =======================
  341. console.log('# login button negatives');
  342. const badLogin = async (qs, re, label) => {
  343. const before = p.messages.length;
  344. await p.goto(ID + '/login' + qs);
  345. const url = await here(p);
  346. const msg = await txt(p, '#errormessage');
  347. check(label + ' → error page, no redirect', url.startsWith(ID + '/login') && re.test(msg), url + ' | ' + msg);
  348. p.messages.splice(before); // the page's own 400 is expected in the console
  349. };
  350. await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://evil.example.org/callback'), /not one of the origins/, 'foreign origin');
  351. await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://127.0.0.1:8373/callback'), /not one of the origins/, "the other app's origin");
  352. await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://127.0.0.1:[email protected]/'), /not one of the origins/, 'user@host trick');
  353. await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('javascript:alert(1)'), /http/, 'javascript: URL');
  354. await badLogin('?key=' + keyA, /missing/, 'no return URL');
  355. await badLogin('?key=pk_00000000000000000000000000000000&return=' + encodeURIComponent(TA + '/callback'), /no app has this API key/, 'unknown key');
  356. await badLogin('?return=' + encodeURIComponent(TA + '/callback'), /missing/, 'no key');
  357. const res = await fetch(ID + '/login?key=' + keyA + '&return=' + encodeURIComponent('http://evil.example.org/'), { redirect: 'manual' });
  358. check('foreign origin: HTTP 400, no Location', res.status === 400 && !res.headers.get('location'), res.status + ' ' + res.headers.get('location'));
  359. await shot(p, 'error');
  360. await p.goto(ID + '/signin/0123456789abcdef0123456789abcdef');
  361. check('unknown request id → "expired" page', /unknown or expired/.test(await txt(p, '#badrequest')));
  362. // ==== 7. the exchange ==================================================================
  363. console.log('# exchange');
  364. const aliceCookie = await apiLogin(ID, 'main', ALICE);
  365. const aliceId = idsOf[aliceCookie][0]; // her default (oldest) identity
  366. check('ids: identity ids are 12-char mpackdb UUIDs', idsOf[aliceCookie].length >= 1 && idsOf[aliceCookie].every(i => /^[0-9a-z]{12}$/.test(i)), JSON.stringify(idsOf[aliceCookie]));
  367. let code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);
  368. let r = await exchange(ID, { key: keyA, secret: secretB, code });
  369. check('wrong secret → 401', r.status === 401, r.t);
  370. r = await exchange(ID, { key: 'pk_x', secret: secretA, code });
  371. check('unknown key → 401', r.status === 401, r.t);
  372. r = await exchange(ID, { key: keyA, secret: secretA, code });
  373. check('right key + secret → 200 { identity } only', r.status === 200 && JSON.stringify(Object.keys(r.j)) === '["identity"]' && r.j.identity === a1.identity, r.t);
  374. r = await exchange(ID, { key: keyA, secret: secretA, code });
  375. check('reused code → 400', r.status === 400 && /already used/.test(r.j.error), r.t);
  376. code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);
  377. r = await exchange(ID, { key: keyB, secret: secretB, code });
  378. check("other app's code → 400", r.status === 400 && /not issued to this app/.test(r.j.error), r.t);
  379. r = await exchange(ID, { key: keyA, secret: secretA, code });
  380. check('a code shown to the wrong app is spent → 400', r.status === 400, r.t);
  381. r = await exchange(ID, { key: keyA, secret: secretA, code: 'nope' });
  382. check('unknown code → 400', r.status === 400, r.t);
  383. r = await exchange(ID, '{"key": "x",');
  384. check('invalid JSON → 400', r.status === 400 && /not valid JSON/.test(r.j.error), r.t);
  385. r = await exchange(ID, '[1]');
  386. check('not an object → 400', r.status === 400 && /object/.test(r.j.error), r.t);
  387. r = await exchange(ID, { key: keyA, secret: secretA });
  388. check('missing field → 400 naming it', r.status === 400 && r.j.error === 'missing field: code', r.t);
  389. r = await exchange(ID, { key: keyA, secret: secretA, code: 'x', email: 'x' });
  390. check('unknown field → 400 naming it', r.status === 400 && r.j.error === 'unknown field: email', r.t);
  391. r = await exchange(ID, { key: keyA, secret: secretA, code: 5 });
  392. check('wrong type → 400 naming it', r.status === 400 && /field code must be a string/.test(r.j.error), r.t);
  393. r = await exchange(ID, '{"key":"\\ud83d\\ude00","secret":"a","code":"b"}');
  394. check('surrogate escape → 400 (not 500)', r.status === 400, r.t);
  395. const g = await fetch(ID + '/api/exchange');
  396. check('GET /api/exchange → 405', g.status === 405);
  397. // expiry, on the short-clock server (grant TTL 1.5 s)
  398. const sc = await apiLogin(IDS, 'short', '[email protected]');
  399. const sApp = (await emit(IDS, 'appCreate', [{ name: 'S', origins: [TA] }], sc)).value;
  400. const sCode = await codeFor(IDS, sc, sApp.app.apiKey, TA + '/callback', idsOf[sc][0]);
  401. await sleep(2000);
  402. r = await exchange(IDS, { key: sApp.app.apiKey, secret: sApp.secret, code: sCode });
  403. check('expired code → 400', r.status === 400 && /expired/.test(r.j.error), r.t);
  404. const sCode2 = await codeFor(IDS, sc, sApp.app.apiKey, TA + '/callback', idsOf[sc][0]);
  405. r = await exchange(IDS, { key: sApp.app.apiKey, secret: sApp.secret, code: sCode2 });
  406. check('fresh code on the short server → 200', r.status === 200, r.t);
  407. // ==== 8. faces: forged sessions (#31), strict fields, other accounts ==================
  408. console.log('# faces');
  409. const forged = { user: { id: 1 } };
  410. for (const [ev, args] of [['appCreate', [{ name: 'X', origins: [TA] }]], ['appUpdate', [1, { name: 'X', origins: [TA] }]], ['appNewSecret', [1]], ['appDelete', [1]], ['chooseIdentity', ['x', 1]]]) {
  411. const f = await emit(ID, ev, [...args, forged]);
  412. check(`forged session argument refused: ${ev}`, framework_refused(f.raw) || (f.value && /not signed in/.test(f.value.error)), f.raw);
  413. }
  414. let e = await emit(ID, 'appCreate', [{ name: 'X', origins: [TA], secret: 'mine' }], aliceCookie);
  415. check('appCreate unknown field → named', e.value.error === 'unknown field: secret' && e.value.field === 'secret', e.raw);
  416. e = await emit(ID, 'appCreate', [{ name: 'X' }], aliceCookie);
  417. check('appCreate missing origins → named', e.value.field === 'origins', e.raw);
  418. e = await emit(ID, 'appCreate', [{ name: '', origins: [TA] }], aliceCookie);
  419. check('appCreate empty name refused', e.value.field === 'name', e.raw);
  420. e = await emit(ID, 'appCreate', [{ name: 'X', origins: 'http://a.b' }], aliceCookie);
  421. check('appCreate origins not a list refused', e.value.field === 'origins', e.raw);
  422. e = await emit(ID, 'appCreate', [{ name: 'X', origins: Array.from({ length: 11 }, (_, i) => 'http://h' + i + '.example') }], aliceCookie);
  423. check('appCreate 11 origins refused', /at most 10/.test(e.value.error), e.raw);
  424. e = await emit(ID, 'appCreate', [{ name: 'X', origins: ['ftp://a.b'] }], aliceCookie);
  425. check('appCreate ftp origin refused', e.value.field === 'origins', e.raw);
  426. const bobCookie = await apiLogin(ID, 'main', BOB);
  427. const aliceApp = (await ssrIds(ID, '/apps', aliceCookie))[0];
  428. check('ids: app ids are 12-char mpackdb UUIDs (read off /apps)', /^[0-9a-z]{12}$/.test(aliceApp || ''), JSON.stringify(aliceApp));
  429. e = await emit(ID, 'appUpdate', [aliceApp, { name: 'hijack', origins: ['http://evil.example'] }], bobCookie);
  430. check("another account cannot edit alice's app", e.value.error === 'no such app', e.raw);
  431. e = await emit(ID, 'appNewSecret', [aliceApp], bobCookie);
  432. check("another account cannot renew alice's secret", e.value.error === 'no such app', e.raw);
  433. e = await emit(ID, 'appDelete', [aliceApp], bobCookie);
  434. check("another account cannot delete alice's app", e.value.error === 'no such app', e.raw);
  435. const l = await fetch(ID + '/login?key=' + keyA + '&return=' + encodeURIComponent(TA + '/callback'), { redirect: 'manual' });
  436. const rid = l.headers.get('location').split('/').pop();
  437. e = await emit(ID, 'chooseIdentity', [rid, aliceId], bobCookie);
  438. check("choosing another account's identity refused", e.value.error === 'no such identity', e.raw);
  439. e = await emit(ID, 'chooseIdentity', [rid, 1], aliceCookie);
  440. check('chooseIdentity wrong type (a number, the old ids) refused', /must be a string/.test(e.value.error), e.raw);
  441. e = await emit(ID, 'appUpdate', [1, { name: 'X', origins: [TA] }], aliceCookie);
  442. check('appUpdate with an old numeric id: no such app', e.value.error === 'no such app', e.raw);
  443. // ==== 9. new secret, delete (browser) ==================================================
  444. console.log('# new secret, delete');
  445. await p.goto(ID + '/apps'); await ready(p);
  446. p.dialogAnswers.push(false);
  447. await p.click('#apps li:nth-child(1) .newsecret');
  448. await sleep(300);
  449. check('new secret: dismissed confirm changes nothing', !(await p.evaluate('!!document.querySelector("#secretbox")')) && p.dialogs.length === 1);
  450. p.dialogAnswers.push(true);
  451. await p.click('#apps li:nth-child(1) .newsecret');
  452. await p.waitForSelector('#secret');
  453. const secretA2 = (await txt(p, '#secret')).trim();
  454. check('new secret shown once, differs', /^sk_[0-9a-f]{48}$/.test(secretA2) && secretA2 !== secretA);
  455. await p.click('#secretdone');
  456. code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);
  457. r = await exchange(ID, { key: keyA, secret: secretA, code });
  458. check('old secret → 401 after renewal', r.status === 401, r.t);
  459. r = await exchange(ID, { key: keyA, secret: secretA2, code });
  460. check('new secret works, same id', r.status === 200 && r.j.identity === a1.identity, r.t);
  461. // the test app still has the old secret: its exchange fails visibly
  462. await pressLogin(TA);
  463. await p.click('#chooselist li:nth-child(1) .choose');
  464. const beforeOld = p.messages.length;
  465. await p.waitFor('!!document.querySelector("#result")');
  466. await sleep(300);
  467. p.messages.splice(beforeOld); // the test app's own 400 page is expected
  468. check('test app with the old secret: exchange refused (401)', (await txt(p, '#status')) === '401', await txt(p, '#result'));
  469. await setupApp(TA, keyA, secretA2);
  470. await pressLogin(TA);
  471. const a3 = await chooseAndReturn(1, TA);
  472. check('test app with the new secret: logged in, same id', a3.identity === a1.identity, JSON.stringify(a3));
  473. // delete app B
  474. await p.goto(ID + '/apps'); await ready(p);
  475. p.dialogAnswers.push(true);
  476. await p.click('#apps li:nth-child(2) .delete');
  477. await p.waitFor('document.querySelectorAll("#apps li").length === 1');
  478. check('app deleted from the list', !(await txt(p, '#apps')).includes('Test app B2'));
  479. await badLogin('?key=' + keyB + '&return=' + encodeURIComponent(TB + '/callback'), /no app has this API key/, 'deleted app key');
  480. // ==== 10. console ======================================================================
  481. const probs = pages.flatMap(x => x.problems().map(m => m.text));
  482. check('no console errors or warnings', probs.length === 0, probs.join(' | '));
  483. } catch (err) {
  484. failed++;
  485. console.log(' FAIL (aborted) ' + (err && err.stack || err));
  486. for (const x of pages) console.log('--- console:\n' + x.dumpConsole());
  487. } finally {
  488. for (const b of [browser1, browser2]) { if (b) { try { await b.close(); } catch {} } }
  489. stopAll();
  490. await sleep(300);
  491. }
  492. console.log(`\n${passed} passed, ${failed} failed`);
  493. process.exit(failed ? 1 : 0);

Branches

Latest commits

  • f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
  • ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
  • a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
  • 98226b41antcolony#40: mission references point to the moved missionsmre
  • ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre