gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitcc063ea2cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmrecc063ea2/tests/apps.mjs

31.5 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. await shot(p, 'secret');
  194. await p.click('#secretdone');
  195. await p.waitFor('!document.querySelector("#secretbox")');
  196. await p.goto(ID + '/apps'); await ready(p);
  197. check('secret not shown again after reload', !(await p.evaluate('document.body.textContent.includes(' + JSON.stringify(secretA) + ')')));
  198. await register('Test app B', 'http://127.0.0.1:8373, http://localhost:8373');
  199. const secretB = (await txt(p, '#secret')).trim();
  200. const keyB = (await txt(p, '#apps li:nth-child(2) .apikey')).trim();
  201. check('second app: own key and secret', keyB !== keyA && secretB !== secretA && /^pk_/.test(keyB));
  202. check('origins listed', (await txt(p, '#apps li:nth-child(2)')).includes('http://127.0.0.1:8373 http://localhost:8373'));
  203. await p.click('#secretdone');
  204. await shot(p, 'list');
  205. // edit: rename B and keep one origin
  206. await p.click('#apps li:nth-child(2) .edit');
  207. await p.waitForSelector('#appform');
  208. check('edit form filled', (await p.evaluate('document.querySelector("#forigins").value')) === 'http://127.0.0.1:8373 http://localhost:8373');
  209. await p.type('#fappname', 'Test app B2', { clear: true });
  210. await p.type('#forigins', 'http://127.0.0.1:8373', { clear: true });
  211. await p.click('#saveapp');
  212. await p.waitFor('/App saved/.test(document.querySelector("#notice").textContent)');
  213. const bRow = await txt(p, '#apps li:nth-child(2)');
  214. check('edit saved (name, origins, key unchanged)', bRow.includes('Test app B2') && !bRow.includes('localhost') && bRow.includes(keyB), bRow);
  215. // ==== 2. the test apps are set up with key + secret ===================================
  216. console.log('# test app setup');
  217. const setupApp = async (base, key, secret) => {
  218. await p.goto(base + '/');
  219. await p.type('#key', key, { clear: true });
  220. await p.type('#secret', secret, { clear: true });
  221. await p.evaluate('window.__old = 1');
  222. await p.click('#savesetup');
  223. await p.waitFor('!window.__old && document.readyState === "complete" && (!!document.querySelector("#login") || !!document.querySelector("#result"))', { label: 'setup saved' });
  224. check('test app ' + base + ' saved key + secret', (await txt(p, '#setupkey')) === key, await txt(p, 'body'));
  225. };
  226. await setupApp(TA, keyA, secretA);
  227. check('test app A set up', (await txt(p, '#setupkey')) === keyA);
  228. await setupApp(TB, keyB, secretB);
  229. // ==== 3. the login button, signed in already, one identity ============================
  230. console.log('# login button: signed in, one identity');
  231. const pressLogin = async (base) => {
  232. await p.goto(base + '/');
  233. await p.click('#login');
  234. await p.waitFor('location.pathname.startsWith("/signin/") && !!document.querySelector("#apprequest")', { label: 'ident sign-in page' });
  235. await ready(p);
  236. };
  237. const chooseAndReturn = async (nth, base) => {
  238. await p.waitForSelector('#chooselist');
  239. await p.click(`#chooselist li:nth-child(${nth}) .choose`);
  240. await p.waitFor(`location.href.startsWith(${JSON.stringify(base + '/callback')}) && !!document.querySelector("#result")`, { label: 'back at the app' });
  241. return { identity: (await txt(p, '#identity')).trim(), state: await txt(p, '#userstate'), answer: await txt(p, '#answer'), result: await txt(p, '#result') };
  242. };
  243. await pressLogin(TA);
  244. 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');
  245. check('one identity: still shown, one choice', (await p.evaluate('document.querySelectorAll("#chooselist li").length')) === 1 && (await txt(p, '#chooselist li')).includes('Default'));
  246. check('identities management not shown on the app page', !(await p.evaluate('!!document.querySelector("#identitiessection")')));
  247. await shot(p, 'choose-one');
  248. const a1 = await chooseAndReturn(1, TA);
  249. check('back at test app A with an identity id', /^[2-9a-hj-km-np-z]{5}$/.test(a1.identity), JSON.stringify(a1));
  250. check('the app got ONLY the id (no email, no names)', a1.answer === JSON.stringify({ identity: a1.identity }), a1.answer);
  251. check('test app: new user', a1.state === 'new user');
  252. await shot(p, 'testapp-result');
  253. await pressLogin(TA);
  254. const a2 = await chooseAndReturn(1, TA);
  255. check('same identity, same app → same id', a2.identity === a1.identity && a2.state === 'welcome back', JSON.stringify(a2));
  256. await pressLogin(TB);
  257. const b1 = await chooseAndReturn(1, TB);
  258. check('same identity, second app → the SAME id (ident#23)', b1.identity === a1.identity, JSON.stringify([a1.identity, b1.identity]));
  259. // ==== 4. two identities: the choice matters ============================================
  260. console.log('# two identities');
  261. await p.goto(ID + '/'); await ready(p);
  262. await p.click('#newidentity');
  263. await p.waitForSelector('#identityform');
  264. await p.type('#fidentityname', 'Work');
  265. await p.click('#saveidentity');
  266. await p.waitFor('document.querySelectorAll("#identities li").length === 2');
  267. await pressLogin(TA);
  268. check('two identities offered', (await p.evaluate('document.querySelectorAll("#chooselist li").length')) === 2);
  269. await shot(p, 'choose-two');
  270. const aWork = await chooseAndReturn(2, TA);
  271. 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));
  272. await pressLogin(TA);
  273. const aDef = await chooseAndReturn(1, TA);
  274. check('first identity again → its old id', aDef.identity === a1.identity);
  275. // ==== 5. signed out: ident login first, then the choice ===============================
  276. console.log('# login button: signed out');
  277. const q = await browser2.newPage(); pages.push(q); onDialogs(q);
  278. await viewport(q, 1280);
  279. await q.goto(TB + '/');
  280. await q.click('#login');
  281. await q.waitFor('location.pathname.startsWith("/signin/") && !!document.querySelector("#email")', { label: 'ident login for the app' });
  282. check('signed out: login form under the app banner', (await txt(q, '#apprequest')).includes('Test app B2'));
  283. await shot(q, 'signin-for-app');
  284. const BOB = '[email protected]';
  285. // ident#20 (mission 032): the app login's code step is the page /signin/<rid>/code and
  286. // survives a reload; the code entered after it signs in and the app flow completes
  287. await ready(q);
  288. const ridPath = await q.evaluate('location.pathname');
  289. await q.type('#email', BOB);
  290. await q.click('#sendcode');
  291. await q.waitFor(`location.pathname === ${JSON.stringify(ridPath + '/code')} && !!document.querySelector("#code")`, { label: 'app code page' });
  292. await ready(q);
  293. 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'));
  294. await reload(q);
  295. 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'));
  296. await shot(q, 'code-for-app-reloaded');
  297. await q.type('#code', lastCode('main', BOB));
  298. await q.click('#verify');
  299. // a first login: the optional names come first, then the choice
  300. await q.waitForSelector('#welcome');
  301. 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'));
  302. check('first login via an app: optional names first', /optional/.test(await txt(q, '#welcome')) && !(await q.evaluate('!!document.querySelector("#chooselist")')));
  303. await q.click('#skip');
  304. await q.waitForSelector('#chooselist');
  305. await q.click('#chooselist li:nth-child(1) .choose');
  306. await q.waitFor(`location.href.startsWith(${JSON.stringify(TB + '/callback')}) && !!document.querySelector("#identity")`);
  307. const bob = (await txt(q, '#identity')).trim();
  308. check('another account → another id', /^[2-9a-hj-km-np-z]{5}$/.test(bob) && bob !== b1.identity && bob !== a1.identity);
  309. // an existing account signs in via the app: no names form, straight to the choice
  310. await q.goto(ID + '/'); await ready(q);
  311. await q.click('#signout');
  312. await q.waitForSelector('#email');
  313. await q.goto(TB + '/');
  314. await q.click('#login');
  315. await q.waitFor('!!document.querySelector("#email")');
  316. // ident#20: "Other address" on the app's code page → back to /signin/<rid>, the email form
  317. await ready(q);
  318. const ridPath2 = await q.evaluate('location.pathname');
  319. await q.type('#email', '[email protected]');
  320. await q.click('#sendcode');
  321. await q.waitFor(`location.pathname === ${JSON.stringify(ridPath2 + '/code')} && !!document.querySelector("#back")`, { label: 'app code page 2' });
  322. await ready(q);
  323. await q.click('#back');
  324. await q.waitFor(`location.pathname === ${JSON.stringify(ridPath2)} && !!document.querySelector("#email")`, { label: 'back to the app sign-in' });
  325. 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")')));
  326. await reload(q);
  327. 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")')));
  328. await q.goto(ID + ridPath2 + '/code');
  329. await q.waitForSelector('#email');
  330. check('ident#20: app login: /signin/<rid>/code with nothing pending → back to /signin/<rid>', (await q.evaluate('location.pathname')) === ridPath2);
  331. await browserLogin(q, BOB);
  332. await q.waitForSelector('#chooselist');
  333. check('known account: straight to the choice (no names form)', !(await q.evaluate('!!document.querySelector("#welcome")')));
  334. await q.click('#chooselist li:nth-child(1) .choose');
  335. await q.waitFor(`!!document.querySelector("#identity")`);
  336. check('after an OTP login: same id as before', (await txt(q, '#identity')).trim() === bob);
  337. // ==== 6. the login button refuses: error page, never a redirect =======================
  338. console.log('# login button negatives');
  339. const badLogin = async (qs, re, label) => {
  340. const before = p.messages.length;
  341. await p.goto(ID + '/login' + qs);
  342. const url = await here(p);
  343. const msg = await txt(p, '#errormessage');
  344. check(label + ' → error page, no redirect', url.startsWith(ID + '/login') && re.test(msg), url + ' | ' + msg);
  345. p.messages.splice(before); // the page's own 400 is expected in the console
  346. };
  347. await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://evil.example.org/callback'), /not one of the origins/, 'foreign origin');
  348. await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://127.0.0.1:8373/callback'), /not one of the origins/, "the other app's origin");
  349. await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('http://127.0.0.1:[email protected]/'), /not one of the origins/, 'user@host trick');
  350. await badLogin('?key=' + keyA + '&return=' + encodeURIComponent('javascript:alert(1)'), /http/, 'javascript: URL');
  351. await badLogin('?key=' + keyA, /missing/, 'no return URL');
  352. await badLogin('?key=pk_00000000000000000000000000000000&return=' + encodeURIComponent(TA + '/callback'), /no app has this API key/, 'unknown key');
  353. await badLogin('?return=' + encodeURIComponent(TA + '/callback'), /missing/, 'no key');
  354. const res = await fetch(ID + '/login?key=' + keyA + '&return=' + encodeURIComponent('http://evil.example.org/'), { redirect: 'manual' });
  355. check('foreign origin: HTTP 400, no Location', res.status === 400 && !res.headers.get('location'), res.status + ' ' + res.headers.get('location'));
  356. await shot(p, 'error');
  357. await p.goto(ID + '/signin/0123456789abcdef0123456789abcdef');
  358. check('unknown request id → "expired" page', /unknown or expired/.test(await txt(p, '#badrequest')));
  359. // ==== 7. the exchange ==================================================================
  360. console.log('# exchange');
  361. const aliceCookie = await apiLogin(ID, 'main', ALICE);
  362. const aliceId = idsOf[aliceCookie][0]; // her default (oldest) identity
  363. 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]));
  364. let code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);
  365. let r = await exchange(ID, { key: keyA, secret: secretB, code });
  366. check('wrong secret → 401', r.status === 401, r.t);
  367. r = await exchange(ID, { key: 'pk_x', secret: secretA, code });
  368. check('unknown key → 401', r.status === 401, r.t);
  369. r = await exchange(ID, { key: keyA, secret: secretA, code });
  370. check('right key + secret → 200 { identity } only', r.status === 200 && JSON.stringify(Object.keys(r.j)) === '["identity"]' && r.j.identity === a1.identity, r.t);
  371. r = await exchange(ID, { key: keyA, secret: secretA, code });
  372. check('reused code → 400', r.status === 400 && /already used/.test(r.j.error), r.t);
  373. code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);
  374. r = await exchange(ID, { key: keyB, secret: secretB, code });
  375. check("other app's code → 400", r.status === 400 && /not issued to this app/.test(r.j.error), r.t);
  376. r = await exchange(ID, { key: keyA, secret: secretA, code });
  377. check('a code shown to the wrong app is spent → 400', r.status === 400, r.t);
  378. r = await exchange(ID, { key: keyA, secret: secretA, code: 'nope' });
  379. check('unknown code → 400', r.status === 400, r.t);
  380. r = await exchange(ID, '{"key": "x",');
  381. check('invalid JSON → 400', r.status === 400 && /not valid JSON/.test(r.j.error), r.t);
  382. r = await exchange(ID, '[1]');
  383. check('not an object → 400', r.status === 400 && /object/.test(r.j.error), r.t);
  384. r = await exchange(ID, { key: keyA, secret: secretA });
  385. check('missing field → 400 naming it', r.status === 400 && r.j.error === 'missing field: code', r.t);
  386. r = await exchange(ID, { key: keyA, secret: secretA, code: 'x', email: 'x' });
  387. check('unknown field → 400 naming it', r.status === 400 && r.j.error === 'unknown field: email', r.t);
  388. r = await exchange(ID, { key: keyA, secret: secretA, code: 5 });
  389. check('wrong type → 400 naming it', r.status === 400 && /field code must be a string/.test(r.j.error), r.t);
  390. r = await exchange(ID, '{"key":"\\ud83d\\ude00","secret":"a","code":"b"}');
  391. check('surrogate escape → 400 (not 500)', r.status === 400, r.t);
  392. const g = await fetch(ID + '/api/exchange');
  393. check('GET /api/exchange → 405', g.status === 405);
  394. // expiry, on the short-clock server (grant TTL 1.5 s)
  395. const sc = await apiLogin(IDS, 'short', '[email protected]');
  396. const sApp = (await emit(IDS, 'appCreate', [{ name: 'S', origins: [TA] }], sc)).value;
  397. const sCode = await codeFor(IDS, sc, sApp.app.apiKey, TA + '/callback', idsOf[sc][0]);
  398. await sleep(2000);
  399. r = await exchange(IDS, { key: sApp.app.apiKey, secret: sApp.secret, code: sCode });
  400. check('expired code → 400', r.status === 400 && /expired/.test(r.j.error), r.t);
  401. const sCode2 = await codeFor(IDS, sc, sApp.app.apiKey, TA + '/callback', idsOf[sc][0]);
  402. r = await exchange(IDS, { key: sApp.app.apiKey, secret: sApp.secret, code: sCode2 });
  403. check('fresh code on the short server → 200', r.status === 200, r.t);
  404. // ==== 8. faces: forged sessions (#31), strict fields, other accounts ==================
  405. console.log('# faces');
  406. const forged = { user: { id: 1 } };
  407. for (const [ev, args] of [['appCreate', [{ name: 'X', origins: [TA] }]], ['appUpdate', [1, { name: 'X', origins: [TA] }]], ['appNewSecret', [1]], ['appDelete', [1]], ['chooseIdentity', ['x', 1]]]) {
  408. const f = await emit(ID, ev, [...args, forged]);
  409. check(`forged session argument refused: ${ev}`, framework_refused(f.raw) || (f.value && /not signed in/.test(f.value.error)), f.raw);
  410. }
  411. let e = await emit(ID, 'appCreate', [{ name: 'X', origins: [TA], secret: 'mine' }], aliceCookie);
  412. check('appCreate unknown field → named', e.value.error === 'unknown field: secret' && e.value.field === 'secret', e.raw);
  413. e = await emit(ID, 'appCreate', [{ name: 'X' }], aliceCookie);
  414. check('appCreate missing origins → named', e.value.field === 'origins', e.raw);
  415. e = await emit(ID, 'appCreate', [{ name: '', origins: [TA] }], aliceCookie);
  416. check('appCreate empty name refused', e.value.field === 'name', e.raw);
  417. e = await emit(ID, 'appCreate', [{ name: 'X', origins: 'http://a.b' }], aliceCookie);
  418. check('appCreate origins not a list refused', e.value.field === 'origins', e.raw);
  419. e = await emit(ID, 'appCreate', [{ name: 'X', origins: Array.from({ length: 11 }, (_, i) => 'http://h' + i + '.example') }], aliceCookie);
  420. check('appCreate 11 origins refused', /at most 10/.test(e.value.error), e.raw);
  421. e = await emit(ID, 'appCreate', [{ name: 'X', origins: ['ftp://a.b'] }], aliceCookie);
  422. check('appCreate ftp origin refused', e.value.field === 'origins', e.raw);
  423. const bobCookie = await apiLogin(ID, 'main', BOB);
  424. const aliceApp = (await ssrIds(ID, '/apps', aliceCookie))[0];
  425. check('ids: app ids are 12-char mpackdb UUIDs (read off /apps)', /^[0-9a-z]{12}$/.test(aliceApp || ''), JSON.stringify(aliceApp));
  426. e = await emit(ID, 'appUpdate', [aliceApp, { name: 'hijack', origins: ['http://evil.example'] }], bobCookie);
  427. check("another account cannot edit alice's app", e.value.error === 'no such app', e.raw);
  428. e = await emit(ID, 'appNewSecret', [aliceApp], bobCookie);
  429. check("another account cannot renew alice's secret", e.value.error === 'no such app', e.raw);
  430. e = await emit(ID, 'appDelete', [aliceApp], bobCookie);
  431. check("another account cannot delete alice's app", e.value.error === 'no such app', e.raw);
  432. const l = await fetch(ID + '/login?key=' + keyA + '&return=' + encodeURIComponent(TA + '/callback'), { redirect: 'manual' });
  433. const rid = l.headers.get('location').split('/').pop();
  434. e = await emit(ID, 'chooseIdentity', [rid, aliceId], bobCookie);
  435. check("choosing another account's identity refused", e.value.error === 'no such identity', e.raw);
  436. e = await emit(ID, 'chooseIdentity', [rid, 1], aliceCookie);
  437. check('chooseIdentity wrong type (a number, the old ids) refused', /must be a string/.test(e.value.error), e.raw);
  438. e = await emit(ID, 'appUpdate', [1, { name: 'X', origins: [TA] }], aliceCookie);
  439. check('appUpdate with an old numeric id: no such app', e.value.error === 'no such app', e.raw);
  440. // ==== 9. new secret, delete (browser) ==================================================
  441. console.log('# new secret, delete');
  442. await p.goto(ID + '/apps'); await ready(p);
  443. p.dialogAnswers.push(false);
  444. await p.click('#apps li:nth-child(1) .newsecret');
  445. await sleep(300);
  446. check('new secret: dismissed confirm changes nothing', !(await p.evaluate('!!document.querySelector("#secretbox")')) && p.dialogs.length === 1);
  447. p.dialogAnswers.push(true);
  448. await p.click('#apps li:nth-child(1) .newsecret');
  449. await p.waitForSelector('#secret');
  450. const secretA2 = (await txt(p, '#secret')).trim();
  451. check('new secret shown once, differs', /^sk_[0-9a-f]{48}$/.test(secretA2) && secretA2 !== secretA);
  452. await p.click('#secretdone');
  453. code = await codeFor(ID, aliceCookie, keyA, TA + '/callback', aliceId);
  454. r = await exchange(ID, { key: keyA, secret: secretA, code });
  455. check('old secret → 401 after renewal', r.status === 401, r.t);
  456. r = await exchange(ID, { key: keyA, secret: secretA2, code });
  457. check('new secret works, same id', r.status === 200 && r.j.identity === a1.identity, r.t);
  458. // the test app still has the old secret: its exchange fails visibly
  459. await pressLogin(TA);
  460. await p.click('#chooselist li:nth-child(1) .choose');
  461. const beforeOld = p.messages.length;
  462. await p.waitFor('!!document.querySelector("#result")');
  463. await sleep(300);
  464. p.messages.splice(beforeOld); // the test app's own 400 page is expected
  465. check('test app with the old secret: exchange refused (401)', (await txt(p, '#status')) === '401', await txt(p, '#result'));
  466. await setupApp(TA, keyA, secretA2);
  467. await pressLogin(TA);
  468. const a3 = await chooseAndReturn(1, TA);
  469. check('test app with the new secret: logged in, same id', a3.identity === a1.identity, JSON.stringify(a3));
  470. // delete app B
  471. await p.goto(ID + '/apps'); await ready(p);
  472. p.dialogAnswers.push(true);
  473. await p.click('#apps li:nth-child(2) .delete');
  474. await p.waitFor('document.querySelectorAll("#apps li").length === 1');
  475. check('app deleted from the list', !(await txt(p, '#apps')).includes('Test app B2'));
  476. await badLogin('?key=' + keyB + '&return=' + encodeURIComponent(TB + '/callback'), /no app has this API key/, 'deleted app key');
  477. // ==== 10. console ======================================================================
  478. const probs = pages.flatMap(x => x.problems().map(m => m.text));
  479. check('no console errors or warnings', probs.length === 0, probs.join(' | '));
  480. } catch (err) {
  481. failed++;
  482. console.log(' FAIL (aborted) ' + (err && err.stack || err));
  483. for (const x of pages) console.log('--- console:\n' + x.dumpConsole());
  484. } finally {
  485. for (const b of [browser1, browser2]) { if (b) { try { await b.close(); } catch {} } }
  486. stopAll();
  487. await sleep(300);
  488. }
  489. console.log(`\n${passed} passed, ${failed} failed`);
  490. process.exit(failed ? 1 : 0);

Branches

Latest commits

  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre