#!/usr/bin/env node /* * Gitea Webhook 工作流的离线协议回归。 * 该脚本不加载 server.js,不访问网络,也不写业务 Store;实现落地时可把同一 * 组 fixtures 接入 HTTP/Store/MCP 集成测试。 */ const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const ROOT = path.resolve(__dirname, '..'); const FIXTURE_DIR = path.join(ROOT, 'fixtures', 'gitea-workflow'); const TEST_SECRET = 'fixture-only-webhook-secret'; const BOT_LOGIN = 'ccweb-bot'; function loadFixture(name) { return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8')); } function rawBody(payload) { return JSON.stringify(payload); } function signBody(body, secret = TEST_SECRET) { return crypto.createHmac('sha256', secret).update(body).digest('hex'); } function verifySignature(body, header, secret = TEST_SECRET) { const expected = Buffer.from(signBody(body, secret), 'hex'); const suppliedText = String(header || '').replace(/^sha256=/i, ''); const supplied = /^[0-9a-f]{64}$/i.test(suppliedText) ? Buffer.from(suppliedText, 'hex') : Buffer.alloc(expected.length); return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected); } function normalizeEvent(fixture) { const headers = Object.fromEntries(Object.entries(fixture.headers || {}) .map(([key, value]) => [key.toLowerCase(), value])); const type = headers['x-gitea-event']; if (!['issue_comment', 'pull_request_comment'].includes(type)) { return {accepted: false, reason: 'unsupported_event'}; } const payload = fixture.payload || {}; const repository = payload.repository || {}; const comment = payload.comment || {}; const sender = payload.sender || comment.user || {}; const pullRequest = payload.pull_request; const issue = payload.issue; const resource = type === 'pull_request_comment' || pullRequest ? { kind: 'pull_request', number: Number(pullRequest && pullRequest.number), base: pullRequest && pullRequest.base && pullRequest.base.ref, head: pullRequest && pullRequest.head && pullRequest.head.ref, } : {kind: 'issue', number: Number(issue && issue.number)}; return { accepted: true, instanceId: 'default', deliveryId: headers['x-gitea-delivery'], eventType: type, repo: { owner: String(repository.owner || ''), name: String(repository.name || ''), cloneUrl: repository.clone_url || null, defaultBranch: repository.default_branch || null, }, resource, comment: { id: Number(comment.id), body: String(comment.body || ''), createdAt: comment.created_at || null, }, actor: {login: String(sender.login || ''), isBot: Boolean(sender.is_bot || sender.type === 'Bot')}, }; } function filterEvent(event, {botLogin = BOT_LOGIN, repositoryStatus = 'active'} = {}) { if (!event.accepted) return event; if (event.actor.login.toLowerCase() === botLogin.toLowerCase() || event.actor.isBot) { return {...event, accepted: false, reason: 'bot_self_comment'}; } if (repositoryStatus !== 'active') { return {...event, accepted: false, reason: 'repository_disabled'}; } if (!/(^|[\s(])@ccweb-bot\b/i.test(event.comment.body)) { return {...event, accepted: false, reason: 'missing_mention'}; } const instruction = event.comment.body.replace(/(^|[\s(])@ccweb-bot\b/i, '$1').trim(); return {...event, instruction}; } class DeliveryQueue { constructor() { this.deliveries = new Map(); this.tasks = []; } accept(event) { const deliveryKey = `${event.instanceId}:${event.deliveryId}`; if (this.deliveries.has(deliveryKey)) { return {...this.deliveries.get(deliveryKey), status: 'duplicate'}; } const record = {deliveryKey, status: event.accepted ? 'accepted' : 'ignored'}; this.deliveries.set(deliveryKey, record); if (!event.accepted) return record; const task = { taskId: `task-${this.tasks.length + 1}`, deliveryKey, repoKey: `${event.instanceId}:${event.repo.owner}/${event.repo.name}`, resourceKey: `${event.instanceId}:${event.repo.owner}/${event.repo.name}:${event.resource.kind}:${event.resource.number}`, state: 'queued', }; this.tasks.push(task); record.taskId = task.taskId; return record; } } function recoverTasks(snapshot) { return snapshot.tasks.map((task) => { if (['succeeded', 'failed', 'cancelled', 'aborted', 'waiting_user'].includes(task.state)) { return {...task}; } if (task.state === 'running' || task.state === 'preparing' || task.state === 'aborting') { return task.attempt >= 1 ? {...task, state: 'failed', errorCode: 'restart_retry_exhausted'} : {...task, state: 'retry_wait', attempt: task.attempt + 1, errorCode: 'interrupted_by_restart'}; } return {...task}; }); } function runReplyCompensation(fixture) { const events = []; if (fixture.mcpAccepted && !fixture.mcpCommentVisible) { events.push('reply_retry'); if (!fixture.retryCommentVisible && fixture.restFallbackVisible) { events.push('rest_fallback'); return {state: 'succeeded_with_rest_fallback', events}; } if (fixture.retryCommentVisible) return {state: 'succeeded', events}; return {state: 'failed_reply', events}; } return {state: fixture.mcpCommentVisible ? 'succeeded' : 'failed_reply', events}; } function buildThreadConfig(fixture) { return { cwd: fixture.cwd, mcp_servers: { gitea: { command: fixture.mcpServer.command, args: fixture.mcpServer.args, env: fixture.mcpServer.env, }, }, collaborationMode: fixture.collaborationMode, }; } function isWorkspacePathSafe(candidate, root) { const resolvedRoot = path.resolve(root); const resolvedCandidate = path.resolve(candidate); return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`); } const tests = []; function test(name, fn) { tests.push({name, fn}); } test('合法 Issue/PR 评论规范化并提取指令', () => { const issue = loadFixture('valid-issue-comment.json'); const issueEvent = filterEvent(normalizeEvent(issue)); assert.equal(issueEvent.accepted, true); assert.equal(issueEvent.resource.kind, 'issue'); assert.equal(issueEvent.instruction, issue.expected.instruction); const pr = loadFixture('valid-pr-comment.json'); const prEvent = filterEvent(normalizeEvent(pr)); assert.equal(prEvent.accepted, true); assert.equal(prEvent.resource.kind, 'pull_request'); assert.equal(prEvent.resource.base, pr.expected.base); assert.equal(prEvent.resource.head, pr.expected.head); }); test('HMAC 验签使用 raw body,错误签名不产生任务', () => { const fixture = loadFixture('valid-issue-comment.json'); const body = rawBody(fixture.payload); const signature = signBody(body); assert.equal(verifySignature(body, signature), true); assert.equal(verifySignature(`${body} `, signature), false); assert.equal(verifySignature(body, ''), false); const queue = new DeliveryQueue(); assert.equal(verifySignature(body, '00'.repeat(32)), false); assert.equal(queue.tasks.length, 0); }); test('事件过滤阻止 Bot 自评论、无 mention、非评论和停用仓库', () => { const bot = filterEvent(normalizeEvent(loadFixture('bot-self-comment.json'))); assert.equal(bot.accepted, false); assert.equal(bot.reason, 'bot_self_comment'); for (const fixture of loadFixture('ignored-events.json')) { const result = filterEvent(normalizeEvent(fixture)); assert.equal(result.accepted, false, fixture.name); assert.equal(result.reason, fixture.expected.reason, fixture.name); } const valid = filterEvent(normalizeEvent(loadFixture('valid-issue-comment.json')), {repositoryStatus: 'disabled'}); assert.equal(valid.reason, 'repository_disabled'); }); test('deliveryKey 幂等,重复 Webhook 不增加 task', () => { const fixture = loadFixture('valid-issue-comment.json'); const event = filterEvent(normalizeEvent(fixture)); const queue = new DeliveryQueue(); const first = queue.accept(event); const second = queue.accept(event); assert.equal(first.status, 'accepted'); assert.equal(second.status, 'duplicate'); assert.equal(queue.tasks.length, 1); assert.equal(first.taskId, second.taskId); }); test('重启恢复只重试中断任务一次,终态和 waiting_user 不重跑', () => { const snapshot = loadFixture('queue-recovery.json'); const recovered = recoverTasks(snapshot); const byId = new Map(recovered.map((task) => [task.taskId, task])); assert.equal(byId.get('task-queued').state, 'queued'); assert.equal(byId.get('task-running').state, 'retry_wait'); assert.equal(byId.get('task-running').errorCode, 'interrupted_by_restart'); assert.equal(byId.get('task-running').attempt, 1); assert.equal(byId.get('task-exhausted').state, 'failed'); assert.equal(byId.get('task-waiting').state, 'waiting_user'); assert.equal(byId.get('task-terminal').state, 'succeeded'); }); test('队列恢复遵守同仓库锁', () => { const active = [ {repoKey: 'default:acme/widget', state: 'running'}, {repoKey: 'default:acme/other', state: 'preparing'}, ]; assert.equal(active.filter((task) => task.repoKey === 'default:acme/widget' && ['preparing', 'running', 'aborting'].includes(task.state)).length, 1); assert.equal(active.filter((task) => task.repoKey === 'default:acme/other' && ['preparing', 'running', 'aborting'].includes(task.state)).length, 1); const queued = {repoKey: 'default:acme/widget', state: 'queued'}; assert.equal(queued.state, 'queued'); }); test('回帖缺失只补发一次,失败后 REST 兜底并区分终态', () => { const result = runReplyCompensation(loadFixture('reply-failure.json')); assert.equal(result.state, 'succeeded_with_rest_fallback'); assert.deepEqual(result.events, ['reply_retry', 'rest_fallback']); assert.equal(result.events.filter((event) => event === 'reply_retry').length, 1); }); test('回执策略只发起始已收到,成功不再发送中间状态', () => { const source = fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8'); assert.match(source, /postGiteaWorkflowStatus\(task, '已收到'\)/); for (const status of ['已接收并排队', '运行中', '等待用户补充信息', '已完成,最终回帖已确认', '已完成(REST 兜底回帖)', '回帖状态未知']) { const callPattern = new RegExp( `postGiteaWorkflowStatus\\([^\\n;]*['"]${status}['"]`, ); assert.equal(callPattern.test(source), false, `不应发送中间状态:${status}`); } const settleStart = source.indexOf('async function settleGiteaWorkflowTurn'); const runnerStart = source.indexOf('giteaWorkflowService.setRunner'); const settleSource = source.slice(settleStart, runnerStart); assert.doesNotMatch(settleSource, /postGiteaWorkflowStatus\(/, 'MCP 成功、等待用户和回帖重试过程不应再发状态评论'); assert.match(source, /commandExistsForMcp\(giteaMcpCommand\)/, '启动前必须检查 gitea-mcp 命令是否存在'); assert.match(source, /code: 'gitea_mcp_not_found'/, '缺失 MCP 命令必须使用稳定错误码快速失败'); assert.match(source, /failed_reply: \(task\) => `回帖失败:/); }); test('MCP 线程配置按线程注入且不重复传顶层 model/effort', () => { const config = buildThreadConfig(loadFixture('mcp-thread-config.json')); assert.equal(config.mcp_servers.gitea.command, 'gitea-mcp'); assert.deepEqual(config.mcp_servers.gitea.args.slice(0, 2), ['-t', 'stdio']); assert.equal(config.mcp_servers.gitea.env.GITEA_ACCESS_TOKEN.startsWith('secret-ref:'), true); assert.equal(config.model, undefined); assert.equal(config.effort, undefined); assert.equal(config.collaborationMode.settings.model, 'configured-model'); assert.equal(config.collaborationMode.settings.reasoning_effort, 'medium'); }); test('安全边界:路径、凭据和不可信正文不越权', () => { const fixtureText = fs.readdirSync(FIXTURE_DIR) .map((name) => fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8')) .join('\n'); assert.equal(fixtureText.includes('fixture-only-webhook-secret'), false); assert.equal(fixtureText.includes('Authorization:'), false); assert.equal(fixtureText.includes('..\\'), false); const config = buildThreadConfig(loadFixture('mcp-thread-config.json')); assert.equal(config.cwd.startsWith('/var/lib/ccweb/workspaces/'), true); assert.equal(config.mcp_servers.gitea.env.GITEA_ACCESS_TOKEN.includes('fixture-only'), false); assert.equal(isWorkspacePathSafe(config.cwd, '/var/lib/ccweb/workspaces'), true); assert.equal(isWorkspacePathSafe('/var/lib/ccweb/workspaces/../secrets', '/var/lib/ccweb/workspaces'), false); assert.equal(isWorkspacePathSafe('/tmp/ccweb-workspace', '/var/lib/ccweb/workspaces'), false); }); let failures = 0; for (const current of tests) { try { current.fn(); process.stdout.write(`PASS ${current.name}\n`); } catch (error) { failures += 1; process.stderr.write(`FAIL ${current.name}: ${error.message}\n`); } } if (failures > 0) { process.stderr.write(`\n${failures}/${tests.length} 场景失败\n`); process.exitCode = 1; } else { process.stdout.write(`\nGitea Webhook regression passed: ${tests.length} 场景\n`); }