stoke/test/clone.test.js
kimi-reviewer-andresmgsl 1b990d523a Add stoke repo clone with ephemeral token handling (#13)
Clone repositories from the configured Forgejo instance using the stored
session. The token is passed to git through GIT_CONFIG_* environment-based
config (http.<url>.extraHeader) with GIT_TERMINAL_PROMPT=0, so it never
appears in the remote URL, on the command line, in logs, or in the cloned
repository's .git/config. Git streams its own output and its exit status is
forwarded to the caller.

Supports an optional destination directory plus --branch, --depth and
--origin. Adds tests covering destination handling, exit-status
propagation, remote naming, depth validation and credential redaction.
2026-07-23 22:44:21 +00:00

109 lines
4.3 KiB
JavaScript

const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync, execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
const TOKEN = 'stoke-secret-token-for-clone-tests';
// A local stand-in for the forge: a directory holding bare repositories laid
// out as <owner>/<repo>.git, so config.url can point at it with a file:// URL
// and `repo clone` exercises real git clones without any network.
let root;
let remote;
let work;
let cfg;
function git(args, cwd) {
return execFileSync('git', args, { cwd: cwd || root, encoding: 'utf8' });
}
function run(args, cwd) {
return spawnSync(process.execPath, [CLI, ...args], {
cwd: cwd || work,
encoding: 'utf8',
env: { ...process.env, STOKE_CONFIG_FILE: cfg },
});
}
before(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-clone-test-'));
remote = path.join(root, 'remote');
work = path.join(root, 'work');
const seed = path.join(root, 'seed');
fs.mkdirSync(path.join(remote, 'o'), { recursive: true });
fs.mkdirSync(work);
git(['init', '-b', 'main', seed]);
fs.writeFileSync(path.join(seed, 'README.md'), 'hello from seed\n');
git(['-C', seed, 'add', 'README.md']);
git(['-C', seed, '-c', 'user.name=Tester', '-c', 'user.email=tester@example.com', 'commit', '-m', 'initial']);
git(['clone', '--bare', seed, path.join(remote, 'o', 'r.git')]);
cfg = path.join(root, 'config.json');
fs.writeFileSync(cfg, JSON.stringify({ url: `file://${remote}`, token: TOKEN, login: 'tester' }));
});
after(() => {
fs.rmSync(root, { recursive: true, force: true });
});
test('repo clone rejects an invalid --depth before running git', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--depth', 'zero']);
assert.equal(res.status, 1);
assert.match(res.stderr, /Depth must be a positive integer/);
});
test('repo clone defaults the destination to the repository name', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r']);
assert.equal(res.status, 0, res.stderr);
const dest = path.join(work, 'r');
assert.ok(fs.existsSync(path.join(dest, '.git')));
assert.equal(fs.readFileSync(path.join(dest, 'README.md'), 'utf8'), 'hello from seed\n');
});
test('repo clone honors an explicit destination directory', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'custom-dir']);
assert.equal(res.status, 0, res.stderr);
assert.ok(fs.existsSync(path.join(work, 'custom-dir', '.git')));
});
test('repo clone fails with git\'s status when the destination is not empty', () => {
const dest = path.join(work, 'occupied');
fs.mkdirSync(dest);
fs.writeFileSync(path.join(dest, 'file.txt'), 'in the way\n');
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'occupied']);
assert.equal(res.status, 128);
assert.match(res.stderr, /already exists and is not an empty directory/);
});
test('repo clone propagates git\'s failure for a missing repository', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
assert.equal(res.status, 128);
assert.match(res.stderr, /does not appear to be a git repository|repository.*does not exist/i);
});
test('repo clone --origin sets the remote name', () => {
const res = run(['repo', 'clone', '-o', 'o', '-r', 'r', '--origin', 'upstream', 'named-origin']);
assert.equal(res.status, 0, res.stderr);
const url = git(['-C', path.join(work, 'named-origin'), 'config', 'remote.upstream.url']);
assert.ok(url.trim().endsWith('/o/r.git'));
});
test('repo clone never exposes the token in output or repository config', () => {
const ok = run(['repo', 'clone', '-o', 'o', '-r', 'r', 'redacted']);
assert.equal(ok.status, 0, ok.stderr);
const fail = run(['repo', 'clone', '-o', 'o', '-r', 'nonexistent']);
for (const output of [ok.stdout, ok.stderr, fail.stdout, fail.stderr]) {
assert.ok(!output.includes(TOKEN), 'token leaked into CLI output');
}
const dest = path.join(work, 'redacted');
const gitConfig = fs.readFileSync(path.join(dest, '.git', 'config'), 'utf8');
assert.ok(!gitConfig.includes(TOKEN), 'token persisted in .git/config');
const remoteUrl = git(['-C', dest, 'config', 'remote.origin.url']);
assert.ok(!remoteUrl.includes(TOKEN), 'token persisted in the remote URL');
});