'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; });