320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
#!/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`);
|
||
}
|