gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitcc7bf7bacc7bf7bamission 002 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (289 lets → plain declarations; 213 left: 125 reassigned, 88 loop-bound; no member/import/param clash). gates 200/0, 46/0, 44/0; real-data reads + writes identical (browser modules: var → const only)mrecc7bf7ba/tests/ssh.mjs

22.9 KB

  1. // tests/ssh.mjs — THE GATE OF GIT OVER SSH (ticket gitoria#7): the REAL sshd container (docker/sshd, built here) + the real ssh and git clients
  2. // against this app; keys pasted in Chrome. (tests/push.mjs is the HTTPS gate; this is a copy of its set-up.)
  3. // was: tests/push.mjs — THE GATE OF PUSH AND PULL (ticket gitoria#7). Its own gitoria server + its own ident (copy, mail sink),
  4. // a real Chrome for everything visible (create a repo, the "add code" box, access tokens), and the REAL `git` binary for
  5. // clone / push / fetch over HTTP against the repo address <slug>.gitoria.test (git's http.curloptResolve maps it to 127.0.0.1).
  6. // node tests/push.mjs (GITORIA_GATE_PORT 8700, GITORIA_GATE_IDENT_PORT 8701, GITORIA_GATE_CHROME "8703-8707")
  7. import { spawn, spawnSync } from 'node:child_process';
  8. import { request as httpRequest, createServer } from 'node:http';
  9. import { rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
  10. import { dirname, join, resolve } from 'node:path';
  11. import { fileURLToPath } from 'node:url';
  12. import { randomBytes } from 'node:crypto';
  13. import { launchBrowser } from './cdp.mjs';
  14. import { startIdent } from './identkit.mjs';
  15. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  16. const HERE = dirname(fileURLToPath(import.meta.url));
  17. const APP = resolve(HERE, '..');
  18. const BIN = join(APP, 'bin/hybriel');
  19. const PORT = Number(process.env.GITORIA_GATE_PORT || 8700);
  20. const IDENT_PORT = Number(process.env.GITORIA_GATE_IDENT_PORT || 8701);
  21. const PROXY = Number(process.env.GITORIA_GATE_PROXY || 8702);
  22. const SSHPORT = Number(process.env.GITORIA_GATE_SSH_PORT || 8708);
  23. const SECRET = 'gate-secret-' + Math.random().toString(16).slice(2);
  24. const CONTAINER = 'gitoria-sshd-gate'; // stands in for nginx: buffers a chunked request body and sends it with a length
  25. const [CH_FROM, CH_TO] = (process.env.GITORIA_GATE_CHROME || '8703-8707').split('-').map(Number);
  26. const API = `http://127.0.0.1:${PORT}`;
  27. const BASE = `http://gitoria.test:${PORT}`;
  28. const IDENT_B = `http://ident.gitoria.test:${IDENT_PORT}`;
  29. const REPO = (slug) => `http://${slug}.gitoria.test:${PORT}`;
  30. const hreq = (method, path, host, headers = {}, body = null) => new Promise((res, rej) => {
  31. const rq = httpRequest({ host: '127.0.0.1', port: PORT, path, method, headers: { host, ...headers } }, (r) => { let b = ''; r.setEncoding('utf8'); r.on('data', d => b += d); r.on('end', () => res({ status: r.statusCode, headers: r.headers, body: b })); });
  32. rq.on('error', rej); if (body) { rq.setHeader('content-length', Buffer.byteLength(body)); rq.write(body); } rq.end();
  33. });
  34. process.env.HL_CHROME_ARGS = '--host-resolver-rules=MAP *.gitoria.test 127.0.0.1, MAP gitoria.test 127.0.0.1';
  35. const SCRATCH = join(APP, '.scratch');
  36. const STORE = join(SCRATCH, 'ssh-store');
  37. const WORK = join(STORE, 'work');
  38. const IDENT_DIR = process.env.GITORIA_GATE_IDENT_DIR || [resolve(APP, '../ident.worldapi.org'), '/media/STORAGE/projects/ident.worldapi.org'].find(d => existsSync(join(d, 'project.hl')));
  39. rmSync(STORE, { recursive: true, force: true });
  40. mkdirSync(WORK, { recursive: true });
  41. let failures = 0, passes = 0;
  42. function check(label, ok, detail = '') {
  43. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  44. if (ok) passes++; else failures++;
  45. }
  46. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  47. const J = JSON.stringify;
  48. let pageA = null;
  49. let log = '', server = null, ident = null, proxy = null;
  50. function startProxy() {
  51. proxy = createServer((req, res) => {
  52. const parts = []; req.on('data', d => parts.push(d));
  53. req.on('end', () => {
  54. const body = Buffer.concat(parts);
  55. const headers = { ...req.headers }; delete headers['transfer-encoding']; headers['content-length'] = String(body.length);
  56. const up = httpRequest({ host: '127.0.0.1', port: PORT, path: req.url, method: req.method, headers }, (r) => { res.writeHead(r.statusCode, r.headers); r.pipe(res); });
  57. up.on('error', () => { res.writeHead(502); res.end(); });
  58. up.end(body);
  59. });
  60. });
  61. proxy.listen(PROXY, '127.0.0.1');
  62. }
  63. const browsers = [];
  64. function startServer(extraEnv = {}) {
  65. server = spawn(BIN, ['project.hl'], {
  66. cwd: APP,
  67. env: { ...process.env, GITORIA_PORT: String(PORT), GITORIA_STORAGE: join(STORE, 'mpackdb'), GITORIA_SESSIONS: join(STORE, 'sessions') + '/', GITORIA_WATCH: '0', GITORIA_GIT: join(STORE, 'git'),
  68. GITORIA_PUBLIC_URL: BASE, IDENT_URL: IDENT_B, IDENT_EXCHANGE_URL: ident.base, GITORIA_TICKETS_URL: 'http://127.0.0.1:1', ...extraEnv },
  69. stdio: ['ignore', 'pipe', 'pipe'],
  70. });
  71. server.stdout.on('data', d => log += d); server.stderr.on('data', d => log += d);
  72. }
  73. async function serverUp() {
  74. for (let i = 0; i < 80; i++) { try { const r = await fetch(API + '/api/repos'); if (r.ok) return; } catch {} await sleep(250); }
  75. throw new Error('gitoria did not come up\n' + log);
  76. }
  77. async function stopServer() {
  78. if (!server) return;
  79. try { server.kill('SIGTERM'); } catch {}
  80. await new Promise(r => { if (server.exitCode !== null || server.signalCode !== null) return r(); server.once('exit', r); setTimeout(r, 3000); });
  81. server = null;
  82. }
  83. function patient(page) {
  84. const waitFor = page.waitFor.bind(page);
  85. page.waitFor = (expr, o = {}) => waitFor(expr, { ...o, timeout: (o.timeout || 10000) * 3 });
  86. const goto = page.goto.bind(page);
  87. page.goto = (url, o = {}) => goto(url, { ...o, timeout: (o.timeout || 15000) * 3 });
  88. return page;
  89. }
  90. const hydrated = (page) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label: 'page hydrated' });
  91. const txt = (page, sel) => page.evaluate(`(document.querySelector(${J(sel)}) || {}).textContent || null`);
  92. const has = (page, sel) => page.evaluate(`!!document.querySelector(${J(sel)})`);
  93. const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');
  94. // the real git client. HOST = the repo address mapped to 127.0.0.1; credentials in the URL when given
  95. const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1', HOME: WORK, LC_ALL: 'C' };
  96. function git(cwd, args, slug, extra = {}) {
  97. const pre = slug ? ['-c', `http.curloptResolve=${slug}.gitoria.test:${extra.port || PROXY}:127.0.0.1`] : [];
  98. const { port: _p, ...envExtra } = extra;
  99. // async: the proxy of this gate lives in this process, a blocking spawnSync would starve it
  100. return new Promise((res) => {
  101. const c = spawn('git', [...pre, '-c', 'user.name=Gate', '-c', '[email protected]', '-c', 'init.defaultBranch=main', ...args], { cwd, env: { ...GIT_ENV, ...envExtra } });
  102. let out = ''; c.stdout.on('data', d => out += d); c.stderr.on('data', d => out += d);
  103. const t = setTimeout(() => c.kill('SIGKILL'), 120000);
  104. c.on('close', (code) => { clearTimeout(t); res({ code, out }); });
  105. });
  106. }
  107. const urlOf = (slug, user, pass, port = PROXY) => `http://${user ? `${user}:${pass}@` : ''}${slug}.gitoria.test:${port}/${slug}.git`;
  108. const KEYDIR = join(STORE, 'keys');
  109. mkdirSync(KEYDIR, { recursive: true });
  110. const newKey = (name, type = 'ed25519', extra = []) => {
  111. spawnSync('ssh-keygen', ['-q', '-t', type, ...extra, '-N', '', '-C', name + '@gate', '-f', join(KEYDIR, name)]);
  112. return { file: join(KEYDIR, name), pub: readFileSync(join(KEYDIR, name + '.pub'), 'utf8').trim() };
  113. };
  114. const sshCmd = (key) => `ssh -i ${key.file} -p ${SSHPORT} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR`;
  115. const sgit = (cwd, args, key) => git(cwd, args, null, { GIT_SSH_COMMAND: sshCmd(key) });
  116. const sshRaw = (key, cmd) => new Promise((res) => {
  117. const c = spawn('sh', ['-c', `${sshCmd(key)} [email protected] ${cmd}`]);
  118. let out = ''; c.stdout.on('data', d => out += d); c.stderr.on('data', d => out += d);
  119. const t = setTimeout(() => c.kill('SIGKILL'), 30000);
  120. c.on('close', (code) => { clearTimeout(t); res({ code, out }); });
  121. });
  122. const SURL = (slug) => `ssh://[email protected]:${SSHPORT}/${slug}.git`;
  123. async function addKeyInChrome(A, name, pub) {
  124. await A.evaluate(`(() => { const n = document.querySelector('#keyname'); n.value = ${J(name)}; n.dispatchEvent(new Event('input', { bubbles: true })); const t = document.querySelector('#keytext'); t.value = ${J(pub)}; t.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  125. await A.click('#keysave');
  126. }
  127. try {
  128. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(STORE, 'ident'), port: IDENT_PORT });
  129. const alice = await ident.signIn('[email protected]');
  130. const bob = await ident.signIn('[email protected]');
  131. const APPKEY = await ident.registerApp(alice, 'gitoria (ssh gate)', [BASE]);
  132. startServer({ IDENT_API_KEY: APPKEY.key, IDENT_API_SECRET: APPKEY.secret, GITORIA_SSH_SECRET: SECRET, GITORIA_SSH_PORT: String(SSHPORT) });
  133. await serverUp();
  134. // the sshd container: host network (reaches the app on 127.0.0.1), repos = this store's git folder
  135. mkdirSync(join(STORE, 'git'), { recursive: true });
  136. spawnSync('docker', ['rm', '-f', CONTAINER]);
  137. const built = spawnSync('docker', ['build', '-q', '-t', 'gitoria-sshd-gate', join(APP, 'docker/sshd')], { encoding: 'utf8' });
  138. check('sshd image builds', built.status === 0, built.stderr);
  139. const started = spawnSync('docker', ['run', '-d', '--rm', '--name', CONTAINER, '--network', 'host', '-e', 'GITORIA_SSH_SECRET=' + SECRET, '-e', 'GITORIA_SSH_PORT=' + SSHPORT, '-e', 'GITORIA_URL=' + API, '-v', join(STORE, 'git') + ':/repos', 'gitoria-sshd-gate'], { encoding: 'utf8' });
  140. check('sshd container starts', started.status === 0, started.stderr);
  141. for (let i = 0; i < 40; i++) { const r = spawnSync('sh', ['-c', `ssh-keyscan -p ${SSHPORT} 127.0.0.1 2>/dev/null | grep -c ssh-`], { encoding: 'utf8' }); if (Number(r.stdout) > 0) break; await sleep(250); }
  142. // ---- the internal endpoints: only with the secret, never through the proxy ------------------------------------
  143. const H = `gitoria.test:${PORT}`;
  144. check('internal: /__git/keys without the secret is 403', (await hreq('GET', '/__git/keys?type=ssh-ed25519&key=AAAAC3NzaC1lZDI1NTE5AAAAIx', H)).status === 403);
  145. check('internal: /__git/keys with a wrong secret is 403', (await hreq('GET', '/__git/keys?type=ssh-ed25519&key=AAAAC3NzaC1lZDI1NTE5AAAAIx', H, { 'x-gitoria-secret': 'nope' })).status === 403);
  146. check('internal: with the secret but through a proxy (X-Forwarded-For) is 403', (await hreq('GET', '/__git/access?user=x&slug=y&write=0', H, { 'x-gitoria-secret': SECRET, 'x-forwarded-for': '1.2.3.4' })).status === 403);
  147. check('internal: an unknown key gets an empty answer', (await hreq('GET', '/__git/keys?type=ssh-ed25519&key=AAAAC3NzaC1lZDI1NTE5AAAAIx', H, { 'x-gitoria-secret': SECRET })).body === '');
  148. // ---- alice in Chrome: log in, name, create a repo, add keys -----------------------------------------------------
  149. const b = await launchBrowser({ debugPortRange: [CH_FROM, CH_TO] });
  150. browsers.push(b);
  151. const A = patient(await b.newPage());
  152. pageA = A;
  153. const [ck, cv] = alice.cookie.split('=');
  154. await A.send('Network.enable');
  155. await A.send('Network.setCookie', { name: ck, value: cv, url: IDENT_B + '/' });
  156. await A.goto(BASE + '/');
  157. await A.waitForSelector('#loginbutton');
  158. await hydrated(A);
  159. check('keys: signed out, the main page offers no key form', !(await has(A, '#keyform')));
  160. await A.click('#loginbutton');
  161. await A.waitForSelector('#chooselist', { timeout: 10000 });
  162. await hydrated(A);
  163. await A.click('#chooselist li:nth-child(1) .choose');
  164. await A.waitForSelector('#nameform');
  165. await hydrated(A);
  166. await A.type('#displayname', 'alice');
  167. await A.click('#namesave');
  168. await A.waitFor('!document.querySelector("#nameform") && !!document.querySelector("#createform")', { label: 'named' });
  169. await A.type('#slug', 'gamma');
  170. await A.click('#createsave');
  171. await A.waitForSelector('#created');
  172. await A.waitFor('!!document.querySelector("#keyform")', { label: 'key form' });
  173. check('keys: logged in, "You have no SSH keys yet"', await has(A, '#nokeys'));
  174. const k1 = newKey('laptop');
  175. await addKeyInChrome(A, 'laptop', 'this is not a key');
  176. await A.waitFor('document.querySelector("#keyerror").textContent.length > 0', { label: 'error' });
  177. check('keys: text that is not a key is refused with a reason', /not a public key/.test(await txt(A, '#keyerror')), await txt(A, '#keyerror'));
  178. await addKeyInChrome(A, 'laptop', readFileSync(k1.file, 'utf8').split('\n').slice(0, 2).join('\n'));
  179. await A.waitFor('/PRIVATE/.test(document.querySelector("#keyerror").textContent)', { label: 'private key error' });
  180. check('keys: a PRIVATE key is refused and never stored', true);
  181. await addKeyInChrome(A, 'laptop', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc broken');
  182. await A.waitFor('/not valid/.test(document.querySelector("#keyerror").textContent)', { label: 'invalid error' });
  183. check('keys: a key with a broken body is refused', true);
  184. const weak = newKey('weak', 'rsa', ['-b', '1024']);
  185. await addKeyInChrome(A, 'weak', weak.pub);
  186. await A.waitFor('/2048|not valid/.test(document.querySelector("#keyerror").textContent)', { label: 'weak error' });
  187. check('keys: a 1024-bit RSA key is refused', true);
  188. await addKeyInChrome(A, 'laptop', k1.pub);
  189. await A.waitFor('document.querySelectorAll("#keylist li").length === 1', { label: 'key added' });
  190. await sleep(400);
  191. check('keys: the key is added and listed with name and SHA256 fingerprint', /laptop/.test(await txt(A, '#keylist')) && /SHA256:/.test(await txt(A, '#keylist')) && !(await txt(A, '#keyerror')), (await txt(A, '#keylist')) + ' / error: ' + (await txt(A, '#keyerror')));
  192. const fp = spawnSync('ssh-keygen', ['-l', '-f', k1.file + '.pub'], { encoding: 'utf8' }).stdout.split(' ')[1];
  193. check('keys: the shown fingerprint is the real one', (await txt(A, '#keylist')).includes(fp), fp);
  194. await addKeyInChrome(A, 'again', k1.pub);
  195. await A.waitFor('/already/.test(document.querySelector("#keyerror").textContent)', { label: 'dup' });
  196. check('keys: the same key twice is refused', true);
  197. const rsa = newKey('rsa', 'rsa', ['-b', '3072']);
  198. await A.evaluate('document.querySelector("#keyerror").textContent = ""');
  199. await addKeyInChrome(A, 'old-rsa', rsa.pub);
  200. await A.waitFor('document.querySelectorAll("#keylist li").length === 2', { label: 'rsa added' });
  201. check('keys: a 3072-bit RSA key is accepted', true);
  202. // ---- the box on the (empty) repo's Code page (gitoria#20) ---------------------------------------------------------
  203. await A.goto(REPO('gamma') + '/code');
  204. await A.waitFor('!!document.querySelector("#addcode")');
  205. check('box: the ssh clone command is on the repo page', (await txt(A, '#sshclonecommand')) === `git clone ssh://[email protected]:${SSHPORT}/gamma.git`, await txt(A, '#sshclonecommand'));
  206. check('box: it links to the SSH keys on the main address', (await A.evaluate('document.querySelector("#tosshkeys").getAttribute("href")')) === BASE + '/#sshkeys');
  207. // ---- real ssh + git ------------------------------------------------------------------------------------------------
  208. const W = (n) => join(WORK, n);
  209. let g = await sgit(WORK, ['clone', SURL('gamma'), 'first'], k1);
  210. check('ssh git: cloning the empty repo works', g.code === 0 && /empty repository/.test(g.out), g.out);
  211. writeFileSync(join(W('first'), 'README.md'), '# gamma\n\nPushed over **ssh**.\n');
  212. writeFileSync(join(W('first'), 'big.bin'), randomBytes(3000000));
  213. await git(W('first'), ['add', '.']); await git(W('first'), ['commit', '-qm', 'first over ssh']);
  214. g = await sgit(W('first'), ['push', 'origin', 'main'], k1);
  215. check('ssh git: the owner pushes (3 MB) with the key', g.code === 0 && /main -> main/.test(g.out), g.out);
  216. g = await sgit(WORK, ['clone', SURL('gamma'), 'second'], rsa);
  217. check('ssh git: the owner\'s RSA key clones the pushed commit', g.code === 0 && readFileSync(join(W('second'), 'big.bin')).equals(readFileSync(join(W('first'), 'big.bin'))), g.out);
  218. writeFileSync(join(W('second'), 'more.txt'), 'more\n'); await git(W('second'), ['add', '.']); await git(W('second'), ['commit', '-qm', 'second']);
  219. g = await sgit(W('second'), ['push', 'origin', 'main'], rsa);
  220. check('ssh git: a push with the second key (fast-forward) works', g.code === 0, g.out);
  221. g = await sgit(W('first'), ['pull', '--no-rebase', '--no-edit', 'origin', 'main'], k1);
  222. check('ssh git: pull brings the new commit', g.code === 0 && existsSync(join(W('first'), 'more.txt')), g.out);
  223. await A.goto(REPO('gamma') + '/');
  224. await A.waitFor('/Pushed over/.test((document.querySelector("#homepage") || {}).textContent || "")', { label: 'readme after ssh push' });
  225. check('pushed code: the Readme page shows what was pushed over ssh', true);
  226. const bare = await git(WORK, ['--git-dir', join(STORE, 'git', 'gamma.git'), 'log', '--format=%s']);
  227. check('pushed code: the bare repo holds both commits', bare.out.trim().split('\n').join(',') === 'second,first over ssh', bare.out);
  228. // ---- what must NOT work ------------------------------------------------------------------------------------------
  229. const stranger = newKey('stranger');
  230. g = await sgit(WORK, ['clone', SURL('gamma'), 'nokey'], stranger);
  231. check('ssh git: a key nobody added is refused', g.code !== 0 && /Permission denied|publickey/.test(g.out), g.out);
  232. let r = await sshRaw(k1, 'ls /');
  233. check('ssh: no shell — any other command is refused', r.code !== 0 && /only git clone, fetch and push/.test(r.out), J(r));
  234. r = await sshRaw(k1, '');
  235. check('ssh: an interactive login is refused too', r.code !== 0 && !/repos|bin/.test(r.out), J(r));
  236. r = await sshRaw(k1, "\"git-upload-pack '../../etc'\"");
  237. check('ssh: a path outside the repos is refused', r.code !== 0 && /no such repository/.test(r.out), J(r));
  238. r = await sshRaw(k1, "\"git-upload-pack 'nope.git'\"");
  239. check('ssh: an unknown repo is refused', r.code !== 0 && /no such repository/.test(r.out), J(r));
  240. r = await sshRaw(k1, "\"git-upload-pack 'gamma.git; id'\"");
  241. check('ssh: a command smuggled behind the repo name is refused', r.code !== 0 && !/uid=/.test(r.out), J(r));
  242. const fwd = spawn('sh', ['-c', `${sshCmd(k1)} -o ExitOnForwardFailure=no -L 8709:127.0.0.1:${PORT} -N [email protected]`]);
  243. await sleep(1500);
  244. const viaTunnel = spawnSync('curl', ['-s', '-m', '5', '-o', '/dev/null', '-w', '%{http_code}', 'http://127.0.0.1:8709/api/repos'], { encoding: 'utf8' }).stdout;
  245. fwd.kill();
  246. check('ssh: no port forwarding through a key', viaTunnel !== '200', 'tunnel answered ' + viaTunnel);
  247. // bob: a key of his own may read but not push to alice's repo
  248. const bobCode = await ident.selectorCode(bob, APPKEY, BASE);
  249. const bl = await fetch(API + '/login/callback?ident_code=' + bobCode, { redirect: 'manual' });
  250. const bobCookie = (bl.headers.get('set-cookie') || '').split(';')[0];
  251. await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie: bobCookie }, body: J({ t: 'emit', i: 1, event: 'gitoriaSaveName', payload: ['bob'] }) });
  252. const bobKey = newKey('bob');
  253. const bk = await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie: bobCookie }, body: J({ t: 'emit', i: 2, event: 'gitoriaAddKey', payload: ['bobs', bobKey.pub] }) });
  254. const bkt = await bk.text();
  255. check('keys: bob adds his own key (a face, own session)', !!(JSON.parse(bkt).value || {}).row, bkt);
  256. g = await sgit(WORK, ['clone', SURL('gamma'), 'bobs'], bobKey);
  257. check('ssh git: another user\'s key may read (public repo)', g.code === 0 && existsSync(join(W('bobs'), 'more.txt')), g.out);
  258. writeFileSync(join(W('bobs'), 'evil.txt'), 'x\n'); await git(W('bobs'), ['add', '.']); await git(W('bobs'), ['commit', '-qm', 'evil']);
  259. g = await sgit(W('bobs'), ['push', 'origin', 'main'], bobKey);
  260. check('ssh git: another user\'s key may not push — only the owner may', g.code !== 0 && /only the owner/.test(g.out), g.out);
  261. const bd = await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie: bobCookie }, body: J({ t: 'emit', i: 3, event: 'gitoriaAddKey', payload: ['stolen', k1.pub] }) });
  262. const bdt = await bd.text();
  263. check('keys: alice\'s key cannot be added again by bob', /already/.test(bdt), bdt);
  264. const fk = await fetch(API + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ t: 'emit', i: 4, event: 'gitoriaAddKey', payload: ['x', stranger.pub, { user: { id: 'y' } }] }) });
  265. const fkt = await fk.text();
  266. const fkr = await sgit(WORK, ['ls-remote', SURL('gamma')], stranger);
  267. check('keys: a forged session argument adds no key', fkr.code !== 0, fkt);
  268. // remove a key: stops at once; the other one still works
  269. await A.goto(BASE + '/');
  270. await A.waitFor('document.querySelectorAll("#keylist li").length === 2', { label: 'keys after reload' });
  271. await hydrated(A);
  272. check('keys: the list survives a reload', (await txt(A, '#keylist')).includes('laptop'));
  273. await A.evaluate('document.querySelector("#keylist li .removekey").click()');
  274. await A.waitFor('document.querySelectorAll("#keylist li").length === 1', { label: 'one removed' });
  275. const left = (await txt(A, '#keylist')).includes('laptop') ? 'laptop' : 'old-rsa';
  276. g = await sgit(W('first'), ['ls-remote', 'origin'], rsa);
  277. const g2 = await sgit(W('first'), ['ls-remote', 'origin'], k1);
  278. check('keys: a removed key stops working at once, the other one still works', left === 'laptop' && g.code !== 0 && g2.code === 0, `left=${left} rsa=${g.code} laptop=${g2.code}`);
  279. await A.click('#logout');
  280. await A.waitFor('!document.querySelector("#keyform")', { label: 'key form gone at logout' });
  281. check('keys: logout hides the key form', !(await has(A, '#keyform')) && !(await has(A, '.removekey')));
  282. for (const [w, name] of [[390, 'phone'], [1280, 'wide']]) {
  283. await A.send('Emulation.setDeviceMetricsOverride', { width: w, height: 900, deviceScaleFactor: 1, mobile: w < 600 });
  284. await A.goto(REPO('gamma') + '/code');
  285. await A.waitFor('!!document.querySelector("#crumbs")');
  286. check(`layout ${w}px: the pushed repo's code page has no horizontal overflow`, await noOverflow(A));
  287. const { data } = await A.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  288. writeFileSync(join(SCRATCH, `ssh-${name}.png`), Buffer.from(data, 'base64'));
  289. }
  290. const problems = A.problems().filter(m => !/favicon|ERR_|Failed to load resource/.test(m.text));
  291. check('browser: no console errors', problems.length === 0, problems.map(m => m.text).join(' | '));
  292. check('server log: no error other than absorbed ones', !/error(?!: absorbed)/i.test(log.replace(/error absorbed[^\n]*/g, '')), log.split('\n').filter(l => /error/i.test(l)).slice(0, 5).join(' | '));
  293. } catch (e) {
  294. failures++;
  295. console.log('FAIL gate crashed — ' + (e && e.stack || e));
  296. if (pageA) { try { console.log('DOM: ' + (await pageA.evaluate('document.querySelector("#sshkeys") ? document.querySelector("#sshkeys").outerHTML.slice(0, 1500) : location.href'))); } catch {} }
  297. console.log('sshd log: ' + spawnSync('docker', ['logs', '--tail', '30', CONTAINER], { encoding: 'utf8' }).stderr);
  298. } finally {
  299. for (const b of browsers) { try { await b.close(); } catch {} }
  300. spawnSync('docker', ['rm', '-f', CONTAINER]);
  301. await stopServer();
  302. if (ident) await ident.stop();
  303. writeFileSync(join(SCRATCH, 'ssh-server.log'), log);
  304. console.log(`${passes} passed, ${failures} failed`);
  305. process.exit(failures ? 1 : 0);
  306. }

Branches

Latest commits

  • cc7bf7bamission 002 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (289 lets → plain declarations; 213 left: 125 reassigned, 88 loop-bound; no member/import/param clash). gates 200/0, 46/0, 44/0; real-data reads + writes identical (browser modules: var → const only)mre
  • 090a20c6mission 002 (code order) 2/4: one lib/ file per topic — git.hl split into git (calls, branches, init, temp folder) / homepage / code / pulls / releases (+ git-helpers: paths, ids, |||PR/|||RL markers); repos-helpers, tickets-helpers, transport-helpers; util.hl = localtime + env, storage dir, addresses, lists, text checks, one newest-first sort (was 3 copies); the function routes out of project.hl into lib/api.hl (thin; plumbing in api-helpers.hl), sshgate.hl folded into api.hl + sshkeys.hl keyLine + repos.hl mayPush; 'Make main' and the merge answer out of the faces (code.hl makeMain, pulls.hl pullsView), one login helper (users.hl userOfLoginCode); project.hl is the map. Session-writing routes get &req + &server.sessions. gates 200/0, 46/0, 44/0; real-data identical except /login/failed now shows the parked reason for a browser that already had a session (the old copy-of-req lost it)mre
  • 110c2799mission 002 (code order) 1/4: .hl files out of the root — lib/ (api, git, localtime, markdown, repos, sshgate, sshkeys, tickets, tokens, transport, users), components/styles.hl; jsoncheck.hl removed (imported nowhere); import paths only. gates 200/0, 46/0, 44/0; real-data reads + writes identicalmre
  • fdfb4b1bgitoria: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 200/0, 46/0, 44/0mre
  • 5b46ac84antcolony#40: LOG.md — missions 069/072 are antcolony missions (report paths on Byrodin)mre
  • 5602ff41gitoria: Hybriel master 190aa11d (fc838894 GC correctness, #127 mountKids by reference, #126, #48) — tracker README flat; gates 200/0, 46/0, 44/0mre
  • e85eaf01gitoria: 069 round 2 — hybriel 1a096ad3 not adopted (Markdown SSR still grows); browser gate waits for the server-side logout before restartmre
  • 09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre
  • 3dc43108antcolony#40: mission references point to the moved missionsmre
  • 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
  • 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
  • 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
  • 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
  • fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
  • 4a2d7125initial commitmre