feat(queue): Piece D, reviews as issue comments, raw per-seat token helper (row 12, #1508)
queue move ID in-review posts the review request as a Gitea comment and review record reads verdicts back, so reviews stop being files in docs/plans/reviews/. On a comment round, in-review to waiting-on-jason now needs every listed reviewer's approval for the current round, the same as in-review to done (Filbert r1 C1). scripts/gitea-api.sh reads the raw per-seat token files (lead decisions 37 to 39): config built and checked before curl starts, export attribute cleared, fixed base URL. test-queue.sh skips its live checks outside the canonical root. Darkwing authored. Filbert approved D r2 (cf1d3fd0) after r1 (a2dc2302) and corrected the plan (293747cd). Rocko reviewed the helper (e896192f, 2096b0a3), and Sage's lead check passed under decision 38. Manifest b402fb38, 19 files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user