stoke/test/api.test.js

271 lines
13 KiB
JavaScript
Raw Permalink Normal View History

Audit: fix auth/config bugs, add issue/pr create, tests and docs Fixes found during a full audit of the CLI: - auth logout: remote token revocation always failed with 401 because Forgejo only accepts Basic auth on the token endpoints. Logout now asks for (or accepts) the account password, supports --password, --password-file and --local-only, and clearly reports when the token is left active. - Silent password prompt actually echoed the password on a TTY: overriding rl.write does not suppress readline echo. Switched to the callback readline module and mute _writeToOutput instead (the readline/promises interface does not honor that hook). - Global --config flag was silently ignored: config paths were resolved at require time, before the preAction hook set STOKE_CONFIG_FILE. Paths are now resolved lazily on every access. - XDG_CONFIG_HOME handling put the config in $XDG_CONFIG_HOME/.config/stoke; per the XDG spec it now resolves to $XDG_CONFIG_HOME/stoke. - repo create: --auto-init defaulted to true with no way to disable it; added --no-auto-init. - repo import/import-batch: a GitHub token was required even for non-GitHub services (e.g. --service git), making those imports fail without gh/GITHUB_TOKEN. Tokens are now only auto-resolved for the github service; batch imports resolve per entry and memoize. - Branding leftovers: 'Run: forgejo auth login' hint and forgejo-cli/1.0.0 User-Agent now say stoke (UA tracks pkg.version). - Added request timeouts (30s default, 10m for migrations). - --limit and --team-id are validated as integers instead of silently misbehaving on garbage (NaN made -l show all results). New commands (per the repo's every-operation-becomes-a-command design): - stoke issue create (title/body/body-file/assignees) - stoke pr create (head/base/title/body/body-file) Tests and metadata: - New test suite on the built-in node:test runner (25 tests) covering config resolution/persistence, the API client with a mocked fetch, and end-to-end CLI behavior. npm test previously matched no files. - package.json: engines >=22.12.0 (required by commander@15 — the README claimed Node 18), repository, keywords, author; version 1.1.0. - README: corrected Node requirement, documented repo rename (was missing), issue create, pr create, logout options and revocation caveat, --no-auto-init, XDG behavior, import token rules, testing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:28:23 +00:00
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { ForgejoClient } = require('../src/api');
const pkg = require('../package.json');
const realFetch = global.fetch;
afterEach(() => {
global.fetch = realFetch;
});
function mockFetch(handler) {
const calls = [];
global.fetch = async (url, opts) => {
calls.push({ url, opts });
return handler(url, opts, calls.length);
};
return calls;
}
function jsonResponse(body, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
};
}
test('fromConfig rejects missing credentials with a stoke-branded hint', () => {
assert.throws(() => ForgejoClient.fromConfig(null), /stoke auth login/);
assert.throws(() => ForgejoClient.fromConfig({ url: 'https://x' }), /stoke auth login/);
});
test('trailing slash in base URL is normalized', async () => {
const calls = mockFetch(() => jsonResponse({ ok: true }));
const client = new ForgejoClient('https://forge.test/', 'tok');
await client.get('/user');
assert.equal(calls[0].url, 'https://forge.test/api/v1/user');
});
test('token auth wins and User-Agent matches the package', async () => {
const calls = mockFetch(() => jsonResponse({}));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.get('/user');
const headers = calls[0].opts.headers;
assert.equal(headers.Authorization, 'token tok');
assert.equal(headers['User-Agent'], `stoke/${pkg.version}`);
});
test('withBasicAuth sends Basic credentials and drops the token', async () => {
const calls = mockFetch(() => jsonResponse({}));
const client = new ForgejoClient('https://forge.test', 'tok').withBasicAuth('user', 'pass');
await client.get('/user');
const expected = `Basic ${Buffer.from('user:pass').toString('base64')}`;
assert.equal(calls[0].opts.headers.Authorization, expected);
});
test('deleteToken uses Basic auth (Forgejo rejects token auth on token endpoints)', async () => {
const calls = mockFetch(() => jsonResponse(null, 204));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.deleteToken('user@example.test', 'pass', 'user', 42);
const { url, opts } = calls[0];
assert.equal(url, 'https://forge.test/api/v1/users/user/tokens/42');
assert.equal(opts.method, 'DELETE');
assert.match(opts.headers.Authorization, /^Basic /);
});
test('API errors carry message, status and body', async () => {
mockFetch(() => jsonResponse({ message: 'user does not exist', url: 'https://forge.test/api/swagger' }, 404));
const client = new ForgejoClient('https://forge.test', 'tok');
await assert.rejects(() => client.get('/users/ghost'), (err) => {
assert.equal(err.message, 'user does not exist');
assert.equal(err.status, 404);
assert.equal(err.body.url, 'https://forge.test/api/swagger');
return true;
});
});
test('non-JSON error bodies are surfaced raw', async () => {
mockFetch(() => ({ ok: false, status: 502, text: async () => 'Bad Gateway' }));
const client = new ForgejoClient('https://forge.test', 'tok');
await assert.rejects(() => client.get('/user'), /Bad Gateway/);
});
test('network failures are wrapped with the base URL', async () => {
global.fetch = async () => { throw new Error('ECONNREFUSED'); };
const client = new ForgejoClient('https://forge.test', 'tok');
await assert.rejects(() => client.get('/user'), /Network error reaching https:\/\/forge\.test/);
});
test('getAll paginates until a short page', async () => {
const pageOf = (n, count) => Array.from({ length: count }, (_, i) => ({ id: (n - 1) * 50 + i }));
mockFetch((url) => {
const page = Number(new URL(url).searchParams.get('page'));
if (page === 1) return jsonResponse(pageOf(1, 50));
if (page === 2) return jsonResponse(pageOf(2, 3));
throw new Error('should not fetch beyond a short page');
});
const client = new ForgejoClient('https://forge.test', 'tok');
const all = await client.getAll('/user/repos');
assert.equal(all.length, 53);
});
test('getAll stops on an empty first page', async () => {
const calls = mockFetch(() => jsonResponse([]));
const client = new ForgejoClient('https://forge.test', 'tok');
const all = await client.getAll('/user/repos');
assert.deepEqual(all, []);
assert.equal(calls.length, 1);
});
test('searchUsers unwraps the {data: []} envelope and paginates', async () => {
mockFetch((url) => {
const page = Number(new URL(url).searchParams.get('page'));
if (page === 1) return jsonResponse({ data: Array.from({ length: 50 }, (_, i) => ({ login: `u${i}` })) });
return jsonResponse({ data: [{ login: 'last' }] });
});
const client = new ForgejoClient('https://forge.test', 'tok');
const users = await client.searchUsers('u');
assert.equal(users.length, 51);
assert.equal(users.at(-1).login, 'last');
});
Add apt distribution: deb packaging, registry publish, docs (#1) Implements #1 — stoke installable with apt-get install stoke. Packaging: - scripts/build-deb.sh: builds dist/stoke_<version>_all.deb from a clean staging copy (src + fresh npm ci --omit=dev), pure-JS Architecture: all, Depends: nodejs (>= 22.12), /usr/lib/stoke payload with /usr/bin/stoke symlink, copyright + changelog, normalized permissions. Lintian-clean. - scripts/publish-deb.sh: uploads a .deb to the Forgejo Debian registry (owner/distribution/component parameterized, defaults heavy-duty/ stable/main), authenticating with STOKE_TOKEN or the stoke login token. - scripts/install-apt.sh: consumer-side one-time setup — adds the registry key and apt source, then apt-get install stoke. Falls back to a [trusted=yes] source when apt's sqv verifier rejects the forge's registry signature (known upstream Forgejo signing bug; the script prefers the signed source so setups heal once the forge is fixed). - .forgejo/workflows/release.yml: on v* tags — test, build, publish to the heavy-duty registry, attach the .deb to the release page. Needs a runner and a RELEASE_TOKEN secret with org package write. New command: - stoke pr merge (-n, --method merge|rebase|rebase-merge|squash, --title, --message, --delete-branch) — gap found while merging !2. Docs and housekeeping: - README: 'Install with apt' as the primary installation method with manual setup and dpkg fallback, signature caveat, pr merge reference, Packaging and releasing section with a release checklist. - dist/ gitignored; version bumped to 1.2.0. Verified end-to-end on this machine: built the deb (lintian-clean), published it to the Forgejo Debian registry, installed it with apt-get install stoke via install-apt.sh, and confirmed the installed CLI works against the live forge. The test upload was removed from the personal namespace afterwards; publishing under heavy-duty needs an org-member token (401 reqPackageAccess with this restricted account). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:40:51 +00:00
test('mergePullRequest posts the merge payload to the merge endpoint', async () => {
const calls = mockFetch(() => jsonResponse(null, 200));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.mergePullRequest('owner', 'repo', 7, { Do: 'squash', delete_branch_after_merge: true });
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/pulls/7/merge');
assert.equal(calls[0].opts.method, 'POST');
assert.deepEqual(JSON.parse(calls[0].opts.body), { Do: 'squash', delete_branch_after_merge: true });
});
Audit: fix auth/config bugs, add issue/pr create, tests and docs Fixes found during a full audit of the CLI: - auth logout: remote token revocation always failed with 401 because Forgejo only accepts Basic auth on the token endpoints. Logout now asks for (or accepts) the account password, supports --password, --password-file and --local-only, and clearly reports when the token is left active. - Silent password prompt actually echoed the password on a TTY: overriding rl.write does not suppress readline echo. Switched to the callback readline module and mute _writeToOutput instead (the readline/promises interface does not honor that hook). - Global --config flag was silently ignored: config paths were resolved at require time, before the preAction hook set STOKE_CONFIG_FILE. Paths are now resolved lazily on every access. - XDG_CONFIG_HOME handling put the config in $XDG_CONFIG_HOME/.config/stoke; per the XDG spec it now resolves to $XDG_CONFIG_HOME/stoke. - repo create: --auto-init defaulted to true with no way to disable it; added --no-auto-init. - repo import/import-batch: a GitHub token was required even for non-GitHub services (e.g. --service git), making those imports fail without gh/GITHUB_TOKEN. Tokens are now only auto-resolved for the github service; batch imports resolve per entry and memoize. - Branding leftovers: 'Run: forgejo auth login' hint and forgejo-cli/1.0.0 User-Agent now say stoke (UA tracks pkg.version). - Added request timeouts (30s default, 10m for migrations). - --limit and --team-id are validated as integers instead of silently misbehaving on garbage (NaN made -l show all results). New commands (per the repo's every-operation-becomes-a-command design): - stoke issue create (title/body/body-file/assignees) - stoke pr create (head/base/title/body/body-file) Tests and metadata: - New test suite on the built-in node:test runner (25 tests) covering config resolution/persistence, the API client with a mocked fetch, and end-to-end CLI behavior. npm test previously matched no files. - package.json: engines >=22.12.0 (required by commander@15 — the README claimed Node 18), repository, keywords, author; version 1.1.0. - README: corrected Node requirement, documented repo rename (was missing), issue create, pr create, logout options and revocation caveat, --no-auto-init, XDG behavior, import token rules, testing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:28:23 +00:00
test('createIssue and createPullRequest hit the expected endpoints', async () => {
const calls = mockFetch(() => jsonResponse({ number: 1 }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createIssue('own/er', 'repo', { title: 't' });
await client.createPullRequest('owner', 're po', { title: 't', head: 'h', base: 'b' });
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/own%2Fer/repo/issues');
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/re%20po/pulls');
assert.equal(calls[0].opts.method, 'POST');
assert.equal(JSON.parse(calls[1].opts.body).head, 'h');
});
test('getPullRequest fetches a single pull request', async () => {
const calls = mockFetch(() => jsonResponse({ number: 7, title: 'Fix' }));
const client = new ForgejoClient('https://forge.test', 'tok');
const pr = await client.getPullRequest('owner', 'repo', 7);
assert.equal(pr.number, 7);
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/pulls/7');
assert.equal(calls[0].opts.method, 'GET');
});
test('getIssue fetches a single issue', async () => {
const calls = mockFetch(() => jsonResponse({ number: 7, title: 'Bug' }));
const client = new ForgejoClient('https://forge.test', 'tok');
const issue = await client.getIssue('owner', 'repo', 7);
assert.equal(issue.number, 7);
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7');
assert.equal(calls[0].opts.method, 'GET');
});
test('createIssueComment posts to the issue comments endpoint', async () => {
const calls = mockFetch(() => jsonResponse({ id: 5, html_url: 'https://forge.test/comment/5' }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createIssueComment('owner', 'repo', 7, 'Me too.');
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/comments');
assert.equal(calls[0].opts.method, 'POST');
assert.equal(JSON.parse(calls[0].opts.body).body, 'Me too.');
});
test('createPullRequestComment posts to the issue comments endpoint', async () => {
const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createPullRequestComment('owner', 'repo', 7, 'Looks good.');
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/comments');
assert.equal(calls[0].opts.method, 'POST');
assert.equal(JSON.parse(calls[0].opts.body).body, 'Looks good.');
});
test('createPullRequestReview posts the review event and body', async () => {
const calls = mockFetch(() => jsonResponse({ id: 88 }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', 'Ship it.');
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/pulls/7/reviews');
assert.equal(calls[0].opts.method, 'POST');
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.event, 'APPROVED');
assert.equal(body.body, 'Ship it.');
});
test('createPullRequestReview preserves leading and trailing whitespace in the body', async () => {
const calls = mockFetch(() => jsonResponse({ id: 89 }));
const client = new ForgejoClient('https://forge.test', 'tok');
const rawBody = ' code block prefix\n';
await client.createPullRequestReview('owner', 'repo', 8, 'REQUEST_CHANGES', rawBody);
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.body, rawBody);
});
test('createPullRequestReview omits commit_id unless a commitId is given', async () => {
const calls = mockFetch(() => jsonResponse({ id: 90 }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '');
assert.ok(!('commit_id' in JSON.parse(calls[0].opts.body)));
await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '', { commitId: 'abc123' });
assert.equal(JSON.parse(calls[1].opts.body).commit_id, 'abc123');
});
test('getAll joins pagination with & when the endpoint already has a query', async () => {
const calls = mockFetch(() => jsonResponse([]));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.getAll('/repos/o/r/pulls?state=closed');
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/o/r/pulls?state=closed&limit=50&page=1');
});
test('getAll overrides caller-supplied limit/page instead of duplicating them', async () => {
const calls = mockFetch((url) => {
const page = Number(new URL(url).searchParams.get('page'));
return jsonResponse(page === 1 ? Array.from({ length: 50 }, (_, i) => ({ id: i })) : []);
});
const client = new ForgejoClient('https://forge.test', 'tok');
const all = await client.getAll('/repos/o/r/pulls?state=closed&limit=1&page=9');
assert.equal(all.length, 50);
const first = new URL(calls[0].url).searchParams;
const second = new URL(calls[1].url).searchParams;
assert.deepEqual(first.getAll('limit'), ['50']);
assert.deepEqual(first.getAll('page'), ['1']);
assert.deepEqual(second.getAll('page'), ['2']);
assert.equal(first.get('state'), 'closed');
});
test('release endpoints map to the expected URLs and payloads', async () => {
const calls = mockFetch(() => jsonResponse({ tag_name: '1.0.0' }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.listReleases('owner', 'repo');
await client.getReleaseByTag('owner', 'repo', '1.0.0-rc1');
await client.createRelease('owner', 'repo', { tag_name: '1.0.0', name: '1.0.0', body: '', draft: false, prerelease: false });
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/releases?limit=50&page=1');
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/releases/tags/1.0.0-rc1');
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/releases');
assert.equal(calls[2].opts.method, 'POST');
assert.equal(JSON.parse(calls[2].opts.body).tag_name, '1.0.0');
});
test('label endpoints map to the expected URLs and payloads', async () => {
const calls = mockFetch(() => jsonResponse({ id: 3 }));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.listLabels('owner', 'repo');
await client.createLabel('owner', 'repo', { name: 'release', color: '0e8a16', description: '' });
await client.deleteLabel('owner', 'repo', 3);
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/labels?limit=50&page=1');
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/labels');
assert.equal(calls[1].opts.method, 'POST');
assert.deepEqual(JSON.parse(calls[1].opts.body), { name: 'release', color: '0e8a16', description: '' });
assert.equal(calls[2].url, 'https://forge.test/api/v1/repos/owner/repo/labels/3');
assert.equal(calls[2].opts.method, 'DELETE');
});
test('issue label add/remove hit the issue labels endpoints', async () => {
const calls = mockFetch(() => jsonResponse(null, 204));
const client = new ForgejoClient('https://forge.test', 'tok');
await client.addIssueLabels('owner', 'repo', 7, [3, 4]);
await client.removeIssueLabel('owner', 'repo', 7, 3);
assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels');
assert.equal(calls[0].opts.method, 'POST');
assert.deepEqual(JSON.parse(calls[0].opts.body), { labels: [3, 4] });
assert.equal(calls[1].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/labels/3');
assert.equal(calls[1].opts.method, 'DELETE');
});