import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createHmac } from 'node:crypto' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import express from 'express' import { startFakeGitea } from './helpers/fakeGitea.js' import { startFakeAppwrite } from './helpers/fakeAppwrite.js' const WEBHOOK_TOKEN = 's3cret' let gitea let aw let tmp let originalCwd let server let baseUrl let nextFeedId = 1 /** * Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach (ein Commit) - derselbe Aufbau * wie in test/projectAssignment.test.js (Task 6): `content` traegt das JSON * `{Commits:[...], HeadCommit:{...}}` als String, wie im echten Aktivitaeten-Feed. */ function pushEntry({ sha, at, user, msg }) { const login = user || 'JUSN' const commit = { Sha1: sha, Message: msg || `Commit ${sha}`, AuthorEmail: `${login.toLowerCase()}@example.com`, AuthorName: login, CommitterEmail: `${login.toLowerCase()}@example.com`, CommitterName: login, Timestamp: at, } return { id: nextFeedId++, op_type: 'commit_repo', act_user: { login, full_name: '' }, ref_name: 'refs/heads/main', created: at, content: JSON.stringify({ Commits: [commit], HeadCommit: { Sha1: sha }, CompareURL: '', Len: 1 }), } } /** * Wartet, bis die Fake-Gitea den POST .../transfer fuer `fullName` erhalten hat (Controller- * Ruling R3), statt einen festen `sleep(50)` zu nutzen: `gitea.calls` waechst, SOBALD die * Anfrage beim Fake-Server ankommt - strikt VOR einer etwaigen `setTransferDelay`-Verzoegerung * der Antwort. So laesst sich der Webhook zuverlaessig genau dann abschicken, wenn der Umzug * seine Repo-Sperren bereits haelt, ohne von der Terminzeit des Test-Rechners abzuhaengen. */ function waitForGiteaTransfer(fullName, timeoutMs = 1000) { const transferPath = `/api/v1/repos/${fullName}/transfer` const deadline = Date.now() + timeoutMs return new Promise((resolve, reject) => { ;(function check() { if (gitea.calls.some((c) => c.method === 'POST' && c.path === transferPath)) return resolve() if (Date.now() > deadline) return reject(new Error(`Timeout beim Warten auf POST ${transferPath}`)) setTimeout(check, 5) })() }) } async function post(urlPath, body, jwt) { const headers = { 'Content-Type': 'application/json' } if (jwt) headers.Authorization = `Bearer ${jwt}` const res = await fetch(`${baseUrl}${urlPath}`, { method: 'POST', headers, body: JSON.stringify(body || {}), }) const json = await res.json().catch(() => null) return { status: res.status, body: json } } /** Baut einen Gitea-Push-Webhook-Payload (nur die vom Portal genutzten Felder). */ function pushPayload({ full_name, sha, pusher, branch = 'main' }) { return { ref: `refs/heads/${branch}`, repository: { full_name, name: full_name.split('/').pop(), html_url: `${gitea.baseUrl}/${full_name}`, }, commits: [ { id: sha, message: `Commit ${sha}`, author: { name: pusher }, url: `${gitea.baseUrl}/${full_name}/commit/${sha}`, }, ], pusher: { username: pusher }, after: sha, } } async function webhook(payload) { const bodyText = JSON.stringify(payload) const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex') const res = await fetch(`${baseUrl}/webhook/gitea`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature }, body: bodyText, }) const json = await res.json().catch(() => null) return { status: res.status, body: json } } before(async () => { gitea = await startFakeGitea({ repos: [{ full_name: 'JUSN/D-ner-Nation' }, { full_name: 'JUSN/langsam' }], feeds: { // Wie Task 6 (test/projectAssignment.test.js): genau ein nachtragbarer Push // ('26bc22ec...' von JUSN), der Rest sind Alt-System-/Alt-Branch-Rauschen. 'JUSN/D-ner-Nation': [ pushEntry({ sha: 's1', user: 'webklar-system', at: '2026-09-27T14:50:00Z', msg: 'System-Push' }), pushEntry({ sha: 'k1', user: 'knso', at: '2026-09-27T14:48:00Z', msg: 'Update preview config from ticket system' }), pushEntry({ sha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Bilder aktualisiert' }), { ...pushEntry({ sha: 'x1', at: '2026-09-27T14:46:33Z' }), content: '' }, { id: nextFeedId++, op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } }, ], // p5/JUSN/langsam hat keine Pushes im Feed. }, }) aw = await startFakeAppwrite({ seed: { websiteProjects: [ { $id: 'p1', repoFullName: 'JUSN/D-ner-Nation', projectName: 'D-ner-Nation', customerId: '', ticketId: '' }, { $id: 'p5', repoFullName: 'JUSN/langsam', projectName: 'langsam', customerId: '', ticketId: '' }, ], workorders: [{ $id: 't1', woid: '100115' }], customers: [{ $id: 'c1', name: 'Döner Nation' }], }, }) originalCwd = process.cwd() tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitea-routes-')) process.chdir(tmp) Object.assign(process.env, { GITEA_BASE_URL: gitea.baseUrl, GITEA_API_TOKEN: 'test-token', GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN, APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key', PREVIEW_DEPLOY_ROOT: '', TRAEFIK_DYNAMIC_DIR: '', }) // config.js liest die Umgebung beim Import const [{ default: adminGiteaRoutes }, { default: adminWebsiteProjectsRoutes }, { default: giteaWebhookRoutes }] = await Promise.all([ import('../server/routes/admin/gitea.js'), import('../server/routes/admin/websiteProjects.js'), import('../server/routes/webhook/gitea.js'), ]) const app = express() // rawBody wird fuer die HMAC-Verifikation des Gitea-Webhooks benoetigt - derselbe Aufbau wie server/index.js. app.use( express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf }, }) ) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) app.use('/webhook', giteaWebhookRoutes) server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)) }) baseUrl = `http://127.0.0.1:${server.address().port}` }) after(async () => { await new Promise((resolve) => server.close(resolve)) process.chdir(originalCwd) await gitea.close() await aw.close() await fs.rm(tmp, { recursive: true, force: true }) }) test('import-repos braucht Mitarbeiter-JWT', async () => { assert.equal((await post('/api/admin/gitea/import-repos')).status, 401) const r = await post('/api/admin/gitea/import-repos', {}, 'jwt-admin') assert.equal(r.status, 200); assert.equal(typeof r.body.created, 'number') }) test('assign über die Schnittstelle', async () => { const r = await post('/api/admin/website-projects/p1/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') assert.equal(r.status, 200) assert.deepEqual([r.body.moved.to, r.body.backfilled, r.body.warnings], ['WEBklar/D-ner-Nation', 1, []]) }) test('assign ohne Kunde und Ticket', async () => { const r = await post('/api/admin/website-projects/p1/assign', {}, 'jwt-admin') assert.deepEqual([r.status, r.body.error], [400, 'customerId oder ticketId erforderlich']) }) test('pushes über die Schnittstelle', async () => { const r = await post('/api/admin/website-projects/p1/pushes', {}, 'jwt-admin') assert.deepEqual([r.status, r.body.moved, r.body.backfilled], [200, null, 0]) }) test('Webhook nach Umzug mit altem Namen', async () => { const docs = aw.all('websiteProjects').length const r = await webhook(pushPayload({ full_name: 'JUSN/D-ner-Nation', sha: 'f00dbeef', pusher: 'JUSN' })) assert.equal(r.status, 200) assert.equal(aw.all('websiteProjects').length, docs) assert.ok(aw.all('worksheets').some((w) => w.workorderId === 't1' && w.details.includes('Commit: f00dbee'))) // Fix-Runde 1: die veraltete Payload (repo.html_url/repo.name auf den alten Namen) darf die // Repo-Adresse des laengst umgezogenen Dokuments nicht mit dem alten Namen ueberschreiben. const p1 = aw.get('websiteProjects', 'p1') assert.ok(p1.giteaRepoUrl.endsWith('/WEBklar/D-ner-Nation')) assert.equal(p1.giteaRepoName, 'D-ner-Nation') }) test('Webhook des System-Kontos', async () => { const before = aw.all('worksheets').length await webhook(pushPayload({ full_name: 'WEBklar/D-ner-Nation', sha: 'cafe0001', pusher: 'webklar-system' })) assert.equal(aw.all('worksheets').length, before) }) test('System-Commit an Projekt mit Kunde ohne Ticket legt kein Auto-Ticket an', async () => { gitea.addRepo({ full_name: 'WEBklar/neu-mit-kunde' }) aw.insert('websiteProjects', { $id: 'p_sys', repoFullName: 'WEBklar/neu-mit-kunde', projectName: 'Neu', customerId: 'c1', ticketId: '' }) const before = aw.all('workorders').length await webhook(pushPayload({ full_name: 'WEBklar/neu-mit-kunde', sha: 'cafe0002', pusher: 'webklar-system' })) assert.equal(aw.all('workorders').length, before) assert.equal(aw.get('websiteProjects', 'p_sys').ticketId, '') }) test('Webhook wartet auf laufenden Umzug', async () => { gitea.setTransferDelay(300) const move = post('/api/admin/website-projects/p5/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') // p5: JUSN/langsam await waitForGiteaTransfer('JUSN/langsam') await webhook(pushPayload({ full_name: 'WEBklar/langsam', sha: 'abcd1234', pusher: 'JUSN' })) await move assert.equal(aw.all('websiteProjects').filter((d) => d.repoFullName === 'WEBklar/langsam').length, 1) })