Files
cc-web/scripts/gitea-webhook-workspace-unit.js

244 lines
10 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const {
DeliveryStore,
createGiteaWebhookReceiver,
createGiteaWebhookRoute,
normalizeWebhookEvent,
parseBotMention,
verifyWebhookSignature,
} = require('../lib/gitea-webhook');
const { buildWorkflowMarker } = require('../lib/gitea-workflow-codex');
const {
BlockedWorkspaceError,
createGiteaWorkspaceManager,
createRepositoryLockManager,
normalizeRepository,
withTempGitCredentials,
} = require('../lib/gitea-workspace');
function sign(body, secret) {
return `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
}
function payloadFixture(overrides = {}) {
return {
action: 'created',
repository: {
owner: { login: 'alice' },
name: 'demo',
full_name: 'alice/demo',
clone_url: 'https://gitea.example/alice/demo.git',
default_branch: 'main',
},
issue: { number: 7, title: '修复问题' },
comment: {
id: 19,
body: '@ccweb-bot 请检查这个问题',
user: { id: 11, login: 'alice' },
},
sender: { id: 11, login: 'alice' },
...overrides,
};
}
async function main() {
const secret = 'webhook-secret';
const body = Buffer.from(JSON.stringify(payloadFixture()));
assert.equal(verifyWebhookSignature(body, sign(body, secret), secret), true);
assert.equal(verifyWebhookSignature(body, sign(body, secret).slice(7), secret), true);
assert.equal(verifyWebhookSignature(Buffer.from(`${body}\n`), sign(body, secret), secret), false);
assert.equal(parseBotMention('@ccweb-bot 做事').instruction, '做事');
assert.equal(parseBotMention('提及 @ccweb-bot-extra'), null);
const normalized = normalizeWebhookEvent({
payload: payloadFixture(),
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1' },
instanceId: 'default',
botIdentity: { login: 'ccweb-bot' },
});
assert.equal(normalized.ignored, false);
assert.equal(normalized.repository.fullName, 'alice/demo');
assert.equal(normalized.resource.key, 'issue:7');
assert.equal(normalized.mention.instruction, '请检查这个问题');
assert.equal(normalizeWebhookEvent({
payload: payloadFixture({ comment: { body: '@ccweb-bot 状态', user: { login: 'ccweb-bot' } }, sender: { login: 'ccweb-bot' } }),
headers: { 'x-gitea-event': 'issue_comment' },
botIdentity: { login: 'ccweb-bot' },
}).reason, 'bot_self_comment');
assert.equal(normalizeWebhookEvent({
payload: payloadFixture({ comment: { body: '普通评论', user: { login: 'alice' } } }),
headers: { 'x-gitea-event': 'issue_comment' },
botIdentity: { login: 'ccweb-bot' },
}).reason, 'missing_mention');
assert.equal(normalizeWebhookEvent({
payload: payloadFixture({ comment: { body: `@ccweb-bot 状态\n${buildWorkflowMarker({ taskId: 't', turnId: 'u', resourceKey: 'r' })}`, user: {} }, sender: {} }),
headers: { 'x-gitea-event': 'issue_comment' },
botIdentity: { login: 'ccweb-bot' },
}).reason, 'workflow_marker_comment');
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-webhook-unit-'));
try {
const deliveryFile = path.join(tempRoot, 'deliveries.json');
const store = new DeliveryStore({ filePath: deliveryFile });
const tasks = [];
const receiver = createGiteaWebhookReceiver({
secret,
instanceId: 'default',
deliveryStore: store,
onTask: async (task) => tasks.push(task),
});
const accepted = await receiver.processWebhook({
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1', 'x-gitea-signature': sign(body, secret) },
rawBody: body,
});
assert.equal(accepted.statusCode, 202);
assert.match(accepted.payload.taskId, /^gitea-task-/);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(tasks.length, 1);
const duplicate = await receiver.processWebhook({
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1', 'x-gitea-signature': sign(body, secret) },
rawBody: body,
});
assert.equal(duplicate.statusCode, 200);
assert.equal(duplicate.payload.duplicate, true);
const invalid = await receiver.processWebhook({
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-2', 'x-gitea-signature': 'sha256=bad' },
rawBody: body,
});
assert.equal(invalid.statusCode, 401);
assert.equal(store.has('default:d-2'), false);
const ignored = await receiver.processWebhook({
headers: { 'x-gitea-event': 'push', 'x-gitea-delivery': 'd-3', 'x-gitea-signature': sign(body, secret) },
rawBody: body,
});
assert.equal(ignored.statusCode, 200);
assert.equal(ignored.payload.ignored, true);
const reloaded = new DeliveryStore({ filePath: deliveryFile });
assert.equal(reloaded.has('default:d-1'), true);
const route = createGiteaWebhookRoute(receiver);
const request = new EventEmitter();
request.method = 'POST';
request.url = '/api/gitea/webhook';
request.headers = { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-route', 'x-gitea-signature': sign(body, secret) };
const responseDone = new Promise((resolve) => {
const response = {
headersSent: false,
writeHead(statusCode) { this.statusCode = statusCode; this.headersSent = true; },
end(value) { this.body = JSON.parse(value); resolve(this); },
};
assert.equal(route(request, response, new URL('http://localhost/api/gitea/webhook')), true);
request.emit('data', body);
request.emit('end');
});
const response = await responseDone;
assert.equal(response.statusCode, 202);
assert.equal(response.body.state, 'queued');
assert.equal(route({ method: 'GET', url: '/api/gitea/webhook', headers: {} }, {}, '/api/gitea/webhook'), false);
receiver.updateConfig({ botLogin: 'renamed-bot' });
const renamedBody = Buffer.from(JSON.stringify(payloadFixture({
comment: { id: 20, body: '@renamed-bot 继续处理', user: { id: 11, login: 'alice' } },
})));
const renamed = await receiver.processWebhook({
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-renamed', 'x-gitea-signature': sign(renamedBody, secret) },
rawBody: renamedBody,
});
assert.equal(renamed.statusCode, 202);
assert.equal(renamed.payload.status, 'accepted');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-workspace-unit-'));
try {
assert.throws(() => normalizeRepository({ owner: '../bad', name: 'demo', cloneUrl: 'https://gitea.example/a/demo.git' }), /owner/);
assert.throws(() => normalizeRepository({ owner: 'alice', name: 'demo', cloneUrl: 'http://gitea.example/a/demo.git' }), /HTTPS/);
const calls = [];
const manager = createGiteaWorkspaceManager({
workspaceRoot,
instanceId: 'default',
botUsername: 'ccweb-bot',
gitRunner: async ({ args, cwd, env }) => {
calls.push({ args, cwd, env });
if (args[0] === 'clone') {
fs.mkdirSync(path.join(args[args.length - 1], '.git'), { recursive: true });
}
if (args[0] === 'status') return { code: 0, stdout: '', stderr: '' };
return { code: 0, stdout: '', stderr: '' };
},
});
const repository = { owner: 'alice', name: 'demo', cloneUrl: 'https://gitea.example/alice/demo.git', defaultBranch: 'main' };
const first = await manager.ensureRepository(repository, { token: 'token-value' });
assert.equal(first.status, 'ready');
assert.equal(calls[0].args[0], 'clone');
assert.deepEqual(calls[0].args.slice(0, 7), ['clone', '--origin', 'origin', '--depth', '1', '--single-branch', '--branch']);
assert.equal(calls[0].args[7], 'main');
assert.equal(calls[0].args.includes('token-value'), false);
assert.equal(calls[0].env.GIT_ASKPASS.endsWith('askpass.sh'), true);
await manager.ensureRepository(repository, { token: 'token-value' });
assert.deepEqual(calls.slice(1).map((item) => item.args[0]), ['status', 'fetch', 'checkout', 'merge']);
const dirtyManager = createGiteaWorkspaceManager({
workspaceRoot: path.join(workspaceRoot, 'dirty'),
instanceId: 'default',
gitRunner: async ({ args }) => {
if (args[0] === 'clone') return { code: 0, stdout: '', stderr: '' };
if (args[0] === 'status') return { code: 0, stdout: ' M tracked.txt\n', stderr: '' };
return { code: 0, stdout: '', stderr: '' };
},
});
const dirtyPath = dirtyManager.workspacePath(repository);
fs.mkdirSync(path.join(dirtyPath, '.git'), { recursive: true });
await assert.rejects(() => dirtyManager.ensureRepository(repository, { token: 'token-value' }), (error) => {
assert(error instanceof BlockedWorkspaceError);
assert.equal(error.code, 'blocked_workspace');
return true;
});
const dirtyContinuation = await dirtyManager.ensureRepository(repository, {
token: 'token-value', allowDirty: true, dirtySessionKey: 'default:alice/demo:issue:7',
});
assert.equal(dirtyContinuation.status, 'dirty_ready');
assert.equal(dirtyContinuation.dirtySessionKey, 'default:alice/demo:issue:7');
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-lock-unit-'));
try {
const locks = createRepositoryLockManager({ lockRoot, retryMs: 5, waitTimeoutMs: 1000 });
const order = [];
await Promise.all([
locks.withLock('same-repo', async () => {
order.push('first-start');
await new Promise((resolve) => setTimeout(resolve, 30));
order.push('first-end');
}),
locks.withLock('same-repo', async () => order.push('second')),
]);
assert.deepEqual(order, ['first-start', 'first-end', 'second']);
} finally {
fs.rmSync(lockRoot, { recursive: true, force: true });
}
let seenEnv;
await withTempGitCredentials({ token: 'secret-token', username: 'bot' }, async (env) => {
seenEnv = env;
});
assert.equal(seenEnv.CCWEB_GIT_TOKEN, 'secret-token');
assert.equal(fs.existsSync(seenEnv.GIT_ASKPASS), false, '临时凭据脚本必须在命令完成后清理');
console.log('Gitea webhook/workspace unit checks passed');
}
main().catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});