// scripts/gitea-api.sh with a raw per-seat token file (lead decision 37). // Every credential file here is a dummy written by the test. curl and git // are stubs: curl records its arguments and the config stream it was given, // and never reaches a network. import test from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; const helper = fileURLToPath(new URL('../../../scripts/gitea-api.sh', import.meta.url)); const DUMMY = '0123456789abcdef0123456789abcdef01234567'; function setup(t) { const dir = mkdtempSync(path.join(os.tmpdir(), 'gitea-helper-raw-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const tool = (name, content) => { const p = path.join(dir, name); writeFileSync(p, '#!/usr/bin/env bash\n' + content); chmodSync(p, 0o755); }; // git runs between the helper's two reads of the file, so STUB_SWAP // changes the file there. tool('git', [ 'f="$MOSAIC_GITEA_CREDENTIAL_FILE"', 'case "${STUB_SWAP:-}" in', ' invalid) printf "invalid-token\\n" > "$f";;', ' json) printf "{}" > "$f";;', ' mode) chmod 644 "$f";;', ' symlink) mv "$f" "$f.moved"; ln -s "$f.moved" "$f";;', ' missing) rm -f "$f";;', 'esac', 'printf %s https://git.mosaicstack.dev/mosaicstack/stack.git', '', ].join('\n')); tool('curl', [ 'printf "%s\\n" "$@" > "$STUB_DIR/curl-args"', 'env > "$STUB_DIR/curl-env"', 'while (($#)); do case "$1" in -K) shift; cat "$1" > "$STUB_DIR/curl-cfg";; -o) shift; out="$1";; esac; shift; done', 'printf "{}" > "$out"', 'printf 200', '', ].join('\n')); const cred = (content, mode = 0o600, name = 'gitea-mosaicstack-darkwing.token') => { const p = path.join(dir, name); rmSync(p, { force: true }); writeFileSync(p, content); chmodSync(p, mode); return p; }; const run = (file, extraEnv = {}, argv = ['GET', 'user']) => { const r = spawnSync('bash', [helper, ...argv], { encoding: 'utf8', env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, STUB_DIR: dir, MOSAIC_GITEA_CREDENTIAL_FILE: file, ...extraEnv }, }); const called = existsSync(path.join(dir, 'curl-args')); const args = called ? readFileSync(path.join(dir, 'curl-args'), 'utf8').split('\n') : null; const cfg = called ? readFileSync(path.join(dir, 'curl-cfg'), 'utf8') : null; const env = called ? readFileSync(path.join(dir, 'curl-env'), 'utf8') : null; for (const f of ['curl-args', 'curl-cfg', 'curl-env']) rmSync(path.join(dir, f), { force: true }); return { status: r.status, stdout: r.stdout, stderr: r.stderr, called, args, cfg, env }; }; return { dir, cred, run }; } const header = (token) => `header = "Authorization: token ${token}"\nheader = "Content-Type: application/json"\n`; test('a raw token file, with or without one trailing newline, reaches curl only through the config stream', t => { const s = setup(t); for (const content of [DUMMY, `${DUMMY}\n`]) { const r = s.run(s.cred(content)); assert.equal(r.status, 0, r.stderr); assert.ok(r.called); assert.equal(r.cfg, header(DUMMY)); assert.ok(r.args.includes('https://git.mosaicstack.dev/api/v1/user'), r.args.join(' ')); assert.ok(!r.args.some((a) => a.includes(DUMMY)), 'the token is not in argv'); assert.ok(!r.env.includes(DUMMY), 'the token is not in the environment curl gets'); assert.ok(!r.stdout.includes(DUMMY) && !r.stderr.includes(DUMMY), 'the token is not printed'); assert.equal(r.stdout, '{}'); assert.match(r.stderr, /^HTTP 200$/m); } }); test('the raw path accepts nothing else, and refuses before curl runs', t => { const s = setup(t); const bad = [ ['empty', ''], ['39 characters', DUMMY.slice(1)], ['41 characters', `${DUMMY}8`], ['upper case', DUMMY.toUpperCase().replace(/^0/, 'A')], ['CRLF', `${DUMMY}\r\n`], ['a trailing CR', `${DUMMY}\r`], ['a trailing space', `${DUMMY} `], ['a trailing tab', `${DUMMY}\t`], ['two newlines', `${DUMMY}\n\n`], ['leading space', ` ${DUMMY.slice(1)}`], ['trailing space', `${DUMMY.slice(1)} `], ['a second line', `${DUMMY}\nx`], ['a quote', `${DUMMY.slice(2)}"\n`], ['not hex', `${DUMMY.slice(1)}g`], ['non-ASCII', `${DUMMY.slice(2)}é`], ['80 characters', DUMMY + DUMMY], ]; for (const [what, content] of bad) { const r = s.run(s.cred(content)); assert.equal(r.status, 3, `${what}: ${r.stderr}`); assert.equal(r.called, false, `${what}: curl ran`); assert.equal(r.stdout, '', what); } }); test('the file checks still apply on the raw path: mode, symlink, missing, directory', t => { const s = setup(t); // 000 and 200 pass the group and other check; the read then refuses. for (const mode of [0o640, 0o604, 0o660, 0o644, 0o000, 0o200]) { const r = s.run(s.cred(`${DUMMY}\n`, mode)); assert.equal(r.status, 3, `mode ${mode.toString(8)}`); assert.equal(r.called, false); } const real = s.cred(`${DUMMY}\n`, 0o600, 'real.token'); const link = path.join(s.dir, 'link.token'); symlinkSync(real, link); assert.deepEqual([s.run(link).status, s.run(link).called], [3, false]); assert.deepEqual([s.run(path.join(s.dir, 'missing.token')).status, s.run(path.join(s.dir, 'missing.token')).called], [3, false]); assert.deepEqual([s.run(s.dir).status, s.run(s.dir).called], [3, false]); }); test('the raw path base URL has no override', t => { const s = setup(t); const r = s.run(s.cred(`${DUMMY}\n`), { MOSAIC_GITEA_URL: 'https://evil.example', GITEA_URL: 'https://evil.example', MOSAIC_GITEA_BASE_URL: 'https://evil.example' }); assert.equal(r.status, 0, r.stderr); assert.ok(r.args.includes('https://git.mosaicstack.dev/api/v1/user')); assert.ok(!r.args.some((a) => a.includes('evil'))); }); test('the JSON path is unchanged, and JSON never falls through to the raw path', t => { const s = setup(t); const json = (o) => s.cred(JSON.stringify(o), 0o600, 'mosaic.gitea.json'); const good = s.run(json({ mosaicstack: { url: 'https://git.mosaicstack.dev/', api_token: 'json-dummy' } })); assert.equal(good.status, 0, good.stderr); assert.equal(good.cfg, header('json-dummy')); assert.ok(good.args.includes('https://git.mosaicstack.dev/api/v1/user')); const refused = [ ['another host', { mosaicstack: { url: 'https://evil.example', api_token: 'json-dummy' } }], ['no token', { mosaicstack: { url: 'https://git.mosaicstack.dev' } }], ['an empty token', { mosaicstack: { url: 'https://git.mosaicstack.dev', api_token: '' } }], ['no mosaicstack key', { url: 'https://git.mosaicstack.dev', api_token: 'json-dummy' }], ]; for (const [what, o] of refused) { const r = s.run(json(o)); assert.deepEqual([r.status, r.called], [3, false], what); } // Content that parses as JSON takes the JSON path even when it would pass // the raw pattern. A token of 40 decimal digits refuses there. for (const content of ['null', '"x"', '1234567890123456789012345678901234567890', '1234567890123456789012345678901234567890\n']) { const r = s.run(s.cred(content)); assert.deepEqual([r.status, r.called], [3, false], JSON.stringify(content)); } }); test('a file that changes between the two reads refuses before curl runs, with or without a body', t => { const s = setup(t); const post = ['POST', 'repos/mosaicstack/stack/issues/1508/comments', '{"body":"dummy"}']; // Unchanged, both calls reach curl, and the POST carries its body. for (const argv of [['GET', 'user'], post]) { const r = s.run(s.cred(`${DUMMY}\n`), {}, argv); assert.deepEqual([r.status, r.called, r.cfg], [0, true, header(DUMMY)], r.stderr); } assert.ok(s.run(s.cred(`${DUMMY}\n`), {}, post).args.includes('--data-binary')); for (const swap of ['invalid', 'json', 'mode', 'symlink', 'missing']) { for (const argv of [['GET', 'user'], post]) { const what = `${swap} ${argv[0]}`; const r = s.run(s.cred(`${DUMMY}\n`), { STUB_SWAP: swap }, argv); assert.deepEqual([r.status, r.called, r.stdout], [3, false, ''], what); assert.ok(!r.stderr.includes(DUMMY), what); } } const json = s.cred(JSON.stringify({ mosaicstack: { url: 'https://git.mosaicstack.dev', api_token: 'json-dummy' } }), 0o600, 'mosaic.gitea.json'); const r = s.run(json, { STUB_SWAP: 'invalid' }, post); assert.deepEqual([r.status, r.called], [3, false], 'a JSON file that changes'); }); test('the token reaches no child environment, even with an inherited CFG or SHELLOPTS=allexport', t => { const s = setup(t); const post = ['POST', 'repos/mosaicstack/stack/issues/1508/comments', '{"body":"dummy"}']; const files = [ ['raw', DUMMY, () => s.cred(`${DUMMY}\n`)], ['JSON', 'json-dummy', () => s.cred(JSON.stringify({ mosaicstack: { url: 'https://git.mosaicstack.dev', api_token: 'json-dummy' } }), 0o600, 'mosaic.gitea.json')], ]; const seeds = [ ['an inherited CFG', { CFG: 'inherited harmless value' }], ['SHELLOPTS=allexport', { SHELLOPTS: 'allexport' }], ['both', { CFG: 'inherited harmless value', SHELLOPTS: 'allexport' }], ]; for (const [kind, token, make] of files) { for (const [seed, env] of seeds) { for (const argv of [['GET', 'user'], post]) { const what = `${kind}, ${seed}, ${argv[0]}`; const r = s.run(make(), env, argv); assert.deepEqual([r.status, r.called, r.cfg], [0, true, header(token)], `${what}: ${r.stderr}`); assert.ok(!r.env.includes(token), `${what}: the token is in curl's environment`); assert.ok(!r.args.some((a) => a.includes(token)), `${what}: the token is in argv`); assert.ok(!r.stdout.includes(token) && !r.stderr.includes(token), `${what}: the token is printed`); } } } });