244 lines
10 KiB
JavaScript
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;
|
|
});
|