Files
ForgeFlow/tests/gitea-actions.test.mjs
NuklearRabbit dec3b79793
Managed validation / full (pull_request) Successful in 27s
hygiene: prepare ForgeFlow for public release
2026-09-02 23:37:30 +02:00

344 lines
18 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import giteaModule from '../src/main/gitea-service.cjs';
const { GiteaService } = giteaModule;
function makeStore() {
return { data: { gitea: { baseUrl: 'https://gitea.example.test' } }, getToken: () => 'demo-token' };
}
test('normalizes run payloads from different Actions API shapes', () => {
const service = new GiteaService(makeStore());
const run = service.normalizeRun({
task_id: 42,
index: 7,
workflow_name: 'Deploy',
status: 'success',
commit: { sha: 'a'.repeat(40) },
ref: 'refs/heads/main',
workflow_file: '.gitea/workflows/deploy.yml',
start_time: '2026-07-24T12:00:00Z'
});
assert.equal(run.id, 42);
assert.equal(run.runNumber, 7);
assert.equal(run.conclusion, 'success');
assert.equal(run.headSha, 'a'.repeat(40));
assert.equal(run.headBranch, 'refs/heads/main');
});
test('retries Actions runs without optional filters when a server rejects them', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => {
calls.push(pathname);
if (calls.length === 1) {
const error = new Error('Unsupported query');
error.status = 422;
throw error;
}
return { data: { workflow_runs: [{ id: 11, run_number: 11, status: 'queued', head_sha: 'b'.repeat(40), head_branch: 'main' }] } };
};
const result = await service.listWorkflowRuns({ owner: 'jens', repo: 'app', sha: 'b'.repeat(40), branch: 'main' });
assert.equal(calls.length, 2);
assert.match(calls[0], /head_sha=/);
assert.doesNotMatch(calls[1], /head_sha=/);
assert.equal(result.source, 'runs');
assert.equal(result.runs[0].runNumber, 11);
});
test('falls back to legacy Actions tasks endpoint when runs is unavailable', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => {
calls.push(pathname);
if (pathname.includes('/runs?')) {
const error = new Error('Not found');
error.status = 404;
throw error;
}
return { data: [{ task_id: 9, index: 3, status: 'running', commit_sha: 'c'.repeat(40), branch: 'main' }] };
};
const result = await service.listWorkflowRuns({ owner: 'jens', repo: 'app' });
assert.equal(result.source, 'tasks');
assert.equal(result.runs[0].id, 9);
assert.ok(calls.some((pathname) => pathname.includes('/tasks?')));
});
test('selects the newest matching workflow run', async () => {
const service = new GiteaService(makeStore());
service.listWorkflowRuns = async () => ({ source: 'runs', runs: [
{ id: 1, headSha: 'd'.repeat(40), headBranch: 'main', workflowPath: 'deploy.yml', createdAt: '2026-07-24T10:00:00Z' },
{ id: 2, headSha: 'd'.repeat(40), headBranch: 'main', workflowPath: '.gitea/workflows/deploy.yml', createdAt: '2026-07-24T11:00:00Z' },
{ id: 3, headSha: 'e'.repeat(40), headBranch: 'main', workflowPath: 'deploy.yml', createdAt: '2026-07-24T12:00:00Z' }
] });
const found = await service.findWorkflowRun({ owner: 'jens', repo: 'app', sha: 'd'.repeat(40), branch: 'main', workflowFile: 'deploy.yml' });
assert.equal(found.run.id, 2);
const excludingNewest = await service.findWorkflowRun({ owner: 'jens', repo: 'app', sha: 'd'.repeat(40), branch: 'main', workflowFile: 'deploy.yml', excludeRunIds: [2] });
assert.equal(excludingNewest.run.id, 1);
});
test('checks repository workflow files through the contents API', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => { calls.push(pathname); return { data: { type: 'file' } }; };
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/deploy.yml', ref: 'main' }), true);
assert.match(calls[0], /contents\/\.gitea\/workflows\/deploy\.yml\?ref=main/);
service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; };
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false);
});
test('creates controlled pull requests and reads branch protection', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname, options = {}) => {
calls.push({ pathname, options });
if (pathname.includes('/branches/main')) return { data: { name: 'main', protected: true } };
if (pathname.endsWith('/branch_protections')) return { data: [{ branch_name: 'main', required_approvals: 2, require_signed_commits: true }] };
return { data: { number: 12, html_url: 'https://gitea.test/owner/app/pulls/12' } };
};
const protection = await service.getBranchProtection('owner', 'app', 'main');
assert.equal(protection.protected, true);
assert.equal(protection.requiredApprovals, 2);
const pull = await service.createPullRequest({ owner: 'owner', repo: 'app', head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
assert.equal(pull.number, 12);
const create = calls.find((call) => call.options.method === 'POST');
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
});
test('resolves release attachment metadata before downloading the actual asset', async () => {
const service = new GiteaService(makeStore());
let metadataPath = '';
let requested = '';
service.request = async (pathname) => {
metadataPath = pathname;
return {
data: {
id: 412,
browser_download_url: 'https://gitea.example.test/attachments/release.exe',
},
};
};
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412);
assert.equal(asset.toString(), 'asset');
assert.equal(
metadataPath,
'/repos/Jens/ForgeFlow/releases/107/assets/412',
);
assert.equal(
requested,
'https://gitea.example.test/attachments/release.exe',
);
await assert.rejects(
() => service.downloadReleaseAsset('Jens', 'ForgeFlow', null, 412),
/invalid release ID/,
);
});
test('uses a release-provided browser download URL without requesting metadata again', async () => {
const service = new GiteaService(makeStore());
service.request = async () => { throw new Error('metadata lookup should not run'); };
let requested = '';
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
downloadUrl: 'https://gitea.example.test/attachments/direct.exe',
});
assert.equal(asset.toString(), 'asset');
assert.equal(requested, 'https://gitea.example.test/attachments/direct.exe');
});
test('rewrites Gitea internal HTTP release URLs to the configured public origin', async () => {
const service = new GiteaService(makeStore());
let requested = '';
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
downloadUrl: 'http://192.168.56.10:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe',
});
assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe');
});
test('creates conservative default branch protection rules', async () => {
const service = new GiteaService(makeStore());
let request = null;
service.request = async (pathname, options) => {
request = { pathname, options };
return { data: { rule_name: 'main' } };
};
const result = await service.createBranchProtection('jens', 'app', 'main');
assert.equal(result.rule_name, 'main');
assert.equal(request.options.method, 'POST');
assert.equal(request.options.body.enable_push, false);
assert.equal(request.options.body.enable_force_push, false);
assert.equal(request.options.body.rule_name, 'main');
});
test('creates repository-scoped read-only deploy keys and reuses only safe matches', async () => {
const service = new GiteaService(makeStore());
const publicKey = `ssh-ed25519 ${Buffer.from('public-key-material').toString('base64')} forgeflow:test`;
const requests = [];
service.request = async (pathname, options = {}) => {
requests.push({ pathname, options });
if (!options.method) return { data: [] };
return { data: { id: 41, key: publicKey, read_only: true } };
};
const created = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey });
assert.equal(created.created, true);
assert.equal(requests[1].options.body.read_only, true);
service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: true }] });
const reused = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey });
assert.equal(reused.created, false);
service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: false }] });
await assert.rejects(
() => service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }),
(error) => error.code === 'DEPLOY_KEY_NOT_READ_ONLY',
);
});
test('request sends scoped credentials, parses response types and redacts rejected secrets', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const calls = [];
const diagnostics = { debug: async (...args) => calls.push(['debug', ...args]), warning: async (...args) => calls.push(['warning', ...args]) };
const service = new GiteaService(makeStore(), diagnostics);
globalThis.fetch = async (url, options) => {
calls.push([url, options]);
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'x-test': 'yes' } });
};
const json = await service.request('/user', { method: 'POST', body: { hello: 'world' }, headers: { 'X-Extra': 'value' } });
assert.deepEqual(json.data, { ok: true });
assert.equal(calls[0][1].headers.Authorization, 'token demo-token');
assert.equal(calls[0][1].headers['Content-Type'], 'application/json');
assert.equal(calls[0][1].headers['X-Extra'], 'value');
globalThis.fetch = async () => new Response('plain', { status: 200 });
assert.equal((await service.request('/plain', { responseType: 'text', auth: false })).data, 'plain');
globalThis.fetch = async () => new Response(Uint8Array.from([1, 2, 3]), { status: 200 });
assert.deepEqual((await service.request('/binary', { responseType: 'buffer' })).data, Buffer.from([1, 2, 3]));
globalThis.fetch = async () => new Response(null, { status: 204 });
assert.equal((await service.request('/empty')).data, null);
globalThis.fetch = async () => new Response(JSON.stringify({ message: 'bad demo-token' }), { status: 403, statusText: 'Forbidden' });
await assert.rejects(service.request('/denied'), (error) => error.status === 403 && !error.message.includes('demo-token'));
globalThis.fetch = async () => new Response('not found', { status: 404, statusText: 'Not Found' });
await assert.rejects(service.request('/missing'), (error) => error.status === 404 && error.payload === 'not found');
});
test('request rejects absent credentials and wraps network failures', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const warnings = [];
const service = new GiteaService({ data: { gitea: { baseUrl: 'https://gitea.example.test' } }, getToken: () => '' }, { warning: async (...args) => warnings.push(args) });
await assert.rejects(service.request('/user'), /no Gitea access token/i);
globalThis.fetch = async () => { const error = new Error('connect ECONNREFUSED'); error.code = 'ECONNREFUSED'; throw error; };
await assert.rejects(service.request('/version', { auth: false }), (error) => error.code === 'ECONNREFUSED' && /could not reach/i.test(error.message));
assert.equal(warnings[0][0], 'gitea.request.failed');
});
test('repository pagination, connection validation and simple endpoints preserve API data', async () => {
const service = new GiteaService(makeStore());
let pages = 0;
service.request = async (pathname) => {
if (pathname === '/user') return { data: { login: 'jens' } };
if (pathname === '/version') throw Object.assign(new Error('unsupported'), { status: 404 });
if (pathname.includes('/user/repos')) { pages += 1; return { data: pages === 1 ? Array.from({ length: 50 }, (_, id) => ({ id })) : [{ id: 51 }] }; }
if (pathname.includes('/branches/')) return { data: { name: 'main' } };
return { data: { id: 1 } };
};
const validated = await service.validateConnection('https://gitea.example.test/', 'token');
assert.equal(validated.repositoryCount, 50);
assert.equal(validated.version, null);
pages = 0;
assert.equal((await service.listRepositories()).length, 51);
assert.equal((await service.getRepository('owner space', 'repo/name')).id, 1);
assert.equal((await service.getBranch('owner', 'repo', 'feature/test')).name, 'main');
});
test('branch protection tolerates unsupported APIs but propagates server failures', async () => {
const service = new GiteaService(makeStore());
service.getBranch = async () => ({ protected: false });
service.request = async () => { throw Object.assign(new Error('unsupported'), { status: 404 }); };
const absent = await service.getBranchProtection('owner', 'repo', 'main');
assert.equal(absent.protected, false);
assert.equal(absent.enablePush, null);
service.request = async () => { throw Object.assign(new Error('down'), { status: 500 }); };
await assert.rejects(service.getBranchProtection('owner', 'repo', 'main'), /down/);
});
test('file, release, pull request and deploy-key helpers validate malformed API inputs', async () => {
const service = new GiteaService(makeStore());
service.request = async () => ({ data: [] });
assert.deepEqual(await service.listDeployKeys('owner', 'repo'), []);
assert.deepEqual(await service.listPullRequests({ owner: 'owner', repo: 'repo', limit: 500 }), []);
await assert.rejects(service.ensureReadOnlyDeployKey({ owner: 'owner', repo: 'repo', publicKey: 'invalid' }), /valid SSH public key/i);
await assert.rejects(service.createReadOnlyDeployKey({ owner: 'owner', repo: 'repo', publicKey: 'invalid' }), /valid SSH public key/i);
await assert.rejects(service.deleteDeployKey('owner', 'repo', 0), /valid deploy-key ID/i);
await assert.rejects(service.createPullRequest({ owner: 'owner', repo: 'repo', head: 'a', base: 'b', title: '' }), /1-255/);
await assert.rejects(service.createPullRequest({ owner: 'owner', repo: 'repo', head: 'a', base: 'b', title: 'x'.repeat(256) }), /1-255/);
await assert.rejects(service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'folder' }), /not a file/i);
service.request = async () => ({ data: { encoding: 'base64', content: Buffer.from('hello').toString('base64') } });
assert.equal((await service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' })).decoded, 'hello');
service.request = async () => ({ data: { content: 'plain' } });
assert.equal((await service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' })).decoded, 'plain');
service.request = async () => ({ data: { encoding: 'none' } });
await assert.rejects(service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' }), /readable content/i);
for (const method of ['getLatestRelease', 'getReleaseByTag']) {
service.request = async () => { throw Object.assign(new Error('missing'), { status: 404 }); };
assert.equal(await service[method]('owner', 'repo', 'v1'), null);
service.request = async () => { throw Object.assign(new Error('server'), { status: 500 }); };
await assert.rejects(service[method]('owner', 'repo', 'v1'), /server/);
}
});
test('authenticated downloads keep tokens same-origin and enforce secure redirects', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const service = new GiteaService(makeStore());
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url: String(url), options });
if (calls.length === 1) return new Response(null, { status: 302, headers: { location: 'https://cdn.example.test/release.exe' } });
return new Response('asset', { status: 200 });
};
assert.equal((await service.downloadAuthenticated('/attachments/release.exe')).toString(), 'asset');
assert.equal(calls[0].options.headers.Authorization, 'token demo-token');
assert.equal(calls[1].options.headers.Authorization, undefined);
globalThis.fetch = async () => new Response(null, { status: 302, headers: { location: 'http://cdn.example.test/file' } });
await assert.rejects(service.downloadAuthenticated('/file'), /insecure cross-origin/i);
globalThis.fetch = async () => new Response(null, { status: 302 });
await assert.rejects(service.downloadAuthenticated('/file'), /did not contain a destination/i);
globalThis.fetch = async () => new Response('missing', { status: 404 });
await assert.rejects(service.downloadAuthenticated('/file'), /HTTP 404/i);
let redirects = 0;
globalThis.fetch = async () => new Response(null, { status: 302, headers: { location: `/redirect-${redirects += 1}` } });
await assert.rejects(service.downloadAuthenticated('/file'), /redirect limit/i);
});
test('release downloads and workflow dispatch reject inconsistent evidence', async () => {
const service = new GiteaService(makeStore());
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 0), /invalid release asset ID/i);
service.request = async () => ({ data: { id: 2, browser_download_url: 'https://gitea.example/file' } });
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 3), /different release asset/i);
service.request = async () => ({ data: { id: 3, browser_download_url: '' } });
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 3), /did not provide/i);
service.request = async () => ({ status: 202, data: null });
assert.deepEqual(await service.dispatchWorkflow({ owner: 'owner', repo: 'repo', workflowFile: 'deploy.yml', ref: 'main' }), { accepted: false, status: 202 });
});