Files
cc-web/scripts/gitea-webhook-regression.js

320 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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`);
}