import http from 'node:http' import { createHash } from 'node:crypto' /** * Nachgebautes Gitea 1.25 fuer Tests: nur der REST-Ausschnitt unter `/api/v1`, den das * Kundenportal nutzt (Repos, Transfer, Hooks, Aktivitaeten-Feed, Contents, Orgs, Teams). * Laeuft auf 127.0.0.1 mit einem vom Betriebssystem vergebenen Port. * * Besitzer-/Repo-Namen werden ueberall ohne Ruecksicht auf Gross-/Kleinschreibung * nachgeschlagen, aber in der zuletzt gesetzten Schreibweise gespeichert - wie Gitea es * auch haelt. * * Ein umgezogenes Repo antwortet unter seinem alten Namen (auch fuer Unterpfade wie * `/hooks`, `/activities/feeds`, `/contents/...`, `/transfer`) mit `301` und einer RELATIVEN * `Location` auf denselben (Unter-)Pfad unter dem neuen Namen - so wie echtes Gitea 1.25.2 * (live geprueft: `GET /api/v1/repos/` -> `301`, `location: /api/v1/repos/`). * * @param {object} [options] * @param {Array<{full_name: string, archived?: boolean, private?: boolean, description?: string, default_branch?: string}>} [options.repos] * Anfangsbestand an Repos. * @param {Record>} [options.hooks] * Repo-Webhooks je `"owner/repo"`. `url` ist nur eine Eingabe-Abkuerzung beim Seeden; gespeichert * und ueber die API ausgeliefert wird das Hook-Objekt wie im echten Gitea 1.25 OHNE Top-Level- * `url` (siehe `definitions.Hook` im Produktions-Swagger) - das Ziel steht ausschliesslich unter * `config.url` (uebernimmt das Seed-`url`, wenn `config.url` nicht separat gesetzt wurde). * @param {Record} [options.feeds] * Aktivitaeten-Feed je `"owner/repo"`, neueste zuerst (wie `GET .../activities/feeds` sie liefert). * @param {Record} [options.contents] * Dateiinhalt (Klartext, keine Kodierung) je `"owner/repo:pfad"`, z. B. `"knso/x:.webklar-preview.json"`. * @param {string[]} [options.orgs] * Organisationen, die von Anfang an existieren (mit leerer Owners-Mitgliedschaft). * @param {Record} [options.redirects] * Vorab gesetzte Weiterleitungen (alter `"owner/repo"` -> neuer `"owner/repo"`), z. B. fuer ein * bereits von Hand verschobenes Repo, ohne dass dafuer ein Transfer-Aufruf noetig war. Der Ziel- * Repo-Eintrag muss separat ueber `options.repos` angelegt werden. * @param {string} [options.user] Login, das `GET /user` meldet. Voreinstellung `'webklar-system'`. * @returns {Promise<{ * baseUrl: string, * calls: Array<{method: string, path: string, query: Record, headers: Record, body: any}>, * repo: (fullName: string) => (object|undefined), * addRepo: (repo: object) => object, * hooksOf: (fullName: string) => object[], * setDown: (down: boolean) => void, * setTransferDelay: (ms: number) => void, * setResponseDelay: (ms: number) => void, * setTransferNoop: () => void, * failNext: (spec: {method: string, pathIncludes: string, status: number}) => void, * close: () => Promise, * }>} `calls` waechst live, in der Reihenfolge, in der die Anfragen eintreffen - VOR einer * etwaigen `setTransferDelay`/`setResponseDelay`-Verzoegerung der Antwort. `repo()`/`hooksOf()` * lesen den aktuellen Stand direkt (ohne Weiterleitung ueber einen umgezogenen alten Namen). * `failNext({method, pathIncludes, status})` laesst GENAU die naechste Anfrage, deren Methode * passt und deren Pfad `pathIncludes` enthaelt, mit diesem Status und einer generischen * JSON-Fehlermeldung scheitern (danach wieder normales Verhalten) - fuer Tests, die einen * einzelnen Aufruf gezielt fehlschlagen lassen wollen, ohne den ganzen Server abzuschalten. * `setResponseDelay(ms)` verzoegert JEDE Antwort um `ms` (z.B. fuer einen Timeout-Test). * `setTransferNoop()` laesst genau den naechsten Transfer-Aufruf mit 201 und UNVERAENDERTEM * `full_name` antworten (Gitea meldet Erfolg, hat aber nichts verschoben). */ export async function startFakeGitea(options = {}) { const calls = [] let isDown = false let transferDelayMs = 0 let responseDelayMs = 0 let transferNoopNext = false const pendingFailures = [] let nextRepoId = 1 let nextHookId = 1 let nextOrgId = 1 let nextTeamId = 1 /** @type {Map} Schluessel: `"owner/repo"` klein geschrieben. */ const repos = new Map() /** @type {Map} alter Schluessel -> neuer full_name (kanonische Schreibweise) nach Transfer. */ const redirects = new Map() /** @type {Map} Schluessel: `"owner/repo"` klein geschrieben. */ const hooksByRepo = new Map() /** @type {Map} Schluessel: `"owner/repo"` klein geschrieben. */ const feedsByRepo = new Map() /** @type {Map} Schluessel: `"owner/repo"` klein geschrieben + `:` + Pfad (Original-Gross-/Kleinschreibung). */ const contents = new Map() /** @type {Map} Schluessel: Login klein geschrieben. */ const orgs = new Map() /** @type {Map} */ const teams = new Map() /** @type {Map} Schluessel: Org-Login klein geschrieben. */ const orgHooks = new Map() const userLogin = options.user || 'webklar-system' function keyFrom(a, b) { return (b === undefined ? a : `${a}/${b}`).toLowerCase() } function sleep(ms) { return new Promise((resolve) => { const t = setTimeout(resolve, ms) t.unref?.() }) } function send(res, status, body) { if (body === undefined) { res.writeHead(status) res.end() return } const text = JSON.stringify(body) res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text), }) res.end(text) } function serializeRepo(record) { return { ...record, owner: { ...record.owner } } } function addRepoInternal(def) { const fullName = def?.full_name || '' const slash = fullName.indexOf('/') if (slash < 1) { throw new Error(`fakeGitea: repo full_name muss "owner/repo" sein, bekam "${fullName}"`) } const owner = fullName.slice(0, slash) const name = fullName.slice(slash + 1) const record = { id: nextRepoId++, name, full_name: fullName, owner: { login: owner }, html_url: `${baseUrl}/${fullName}`, default_branch: def.default_branch || 'main', archived: Boolean(def.archived), private: Boolean(def.private), description: def.description || '', empty: false, created_at: def.created_at || new Date().toISOString(), } repos.set(keyFrom(fullName), record) return record } /** * Gitea-foermiges Hook-Objekt OHNE Top-Level-`url` (siehe JSDoc oben): das Ziel steht * ausschliesslich in `config.url`. `h.url` ist nur eine Eingabe-Abkuerzung und landet dort, * wenn die Eingabe kein `config.url` mitbringt. */ function normalizeHook(h) { const config = { content_type: 'json', ...(h.config || {}) } if (config.url === undefined) config.url = h.url return { id: h.id, type: h.type || 'gitea', active: h.active !== undefined ? h.active : true, events: h.events || ['push'], config, } } function serializeHook(h) { return { ...h, config: { ...h.config } } } function createOrgRecord(username, full_name, visibility) { const org = { id: nextOrgId++, username, full_name: full_name || username, visibility: visibility || 'public', ownersTeamId: nextTeamId++, } orgs.set(username.toLowerCase(), org) teams.set(org.ownersTeamId, { id: org.ownersTeamId, name: 'Owners', org: username, members: [] }) return org } function moveMapKey(map, oldKey, newKey) { if (map.has(oldKey)) { map.set(newKey, map.get(oldKey)) map.delete(oldKey) } } function moveContentKeys(oldKey, newKey) { const prefix = `${oldKey}:` for (const [k, v] of [...contents.entries()]) { if (k.startsWith(prefix)) { contents.delete(k) contents.set(`${newKey}${k.slice(oldKey.length)}`, v) } } } function findRepoOrRedirect(owner, name) { const key = keyFrom(owner, name) if (repos.has(key)) return { key, record: repos.get(key) } const redirectTo = redirects.get(key) if (redirectTo) return { key, redirectTo } return null } /* ------------------------------------------------------------- Routen --- */ function handleGetUser(match, searchParams, body, headers, res) { send(res, 200, { login: userLogin, is_admin: true }) } function handleSearchRepos(match, searchParams, body, headers, res) { const all = [...repos.values()] const limit = Number(searchParams.get('limit')) || 50 const page = Number(searchParams.get('page')) || 1 const start = (page - 1) * limit send(res, 200, { ok: true, data: all.slice(start, start + limit).map(serializeRepo) }) } /** 301 mit RELATIVER Location auf denselben (Unter-)Pfad unter dem neuen Namen - wie echtes Gitea. */ function sendRepoRedirect(res, newFullName, suffix = '') { res.writeHead(301, { Location: `/api/v1/repos/${newFullName}${suffix}` }) res.end() } function handleGetRepo(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo) send(res, 200, serializeRepo(found.record)) } function handlePatchRepo(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo) Object.assign(found.record, body || {}) send(res, 200, serializeRepo(found.record)) } async function handleTransfer(match, searchParams, body, headers, res) { if (transferDelayMs > 0) await sleep(transferDelayMs) const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/transfer') const record = found.record // Simuliert eine Gitea-Antwort, die Erfolg meldet, aber tatsaechlich nichts verschoben hat // (Bug-Szenario fuer Controller-Ruling R10 Ein-Zeiler-Absicherung): 201 mit UNVERAENDERTEM // full_name, kein Owner-/Key-Wechsel, keine Weiterleitung. if (transferNoopNext) { transferNoopNext = false return send(res, 201, serializeRepo(record)) } const key = found.key const newOwner = body?.new_owner if (!newOwner) return send(res, 422, { message: 'new_owner erforderlich' }) const newFullName = `${newOwner}/${record.name}` const newKey = keyFrom(newOwner, record.name) if (repos.has(newKey)) { return send(res, 422, { message: `repository already exists [uname: ${newOwner}, name: ${record.name}]`, }) } repos.delete(key) record.owner = { login: newOwner } record.full_name = newFullName repos.set(newKey, record) redirects.set(key, newFullName) moveMapKey(hooksByRepo, key, newKey) moveMapKey(feedsByRepo, key, newKey) moveContentKeys(key, newKey) send(res, 202, serializeRepo(record)) } function handleGetRepoHooks(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/hooks') send(res, 200, (hooksByRepo.get(found.key) || []).map(serializeHook)) } function handleDeleteRepoHook(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/hooks/${match[3]}`) const list = hooksByRepo.get(found.key) if (!list) return send(res, 404, { message: 'Not Found' }) const idx = list.findIndex((h) => String(h.id) === match[3]) if (idx === -1) return send(res, 404, { message: 'Not Found' }) list.splice(idx, 1) send(res, 204) } function handleFeeds(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/activities/feeds') const all = feedsByRepo.get(found.key) || [] const limit = Number(searchParams.get('limit')) || 1000 const page = Number(searchParams.get('page')) || 1 const start = (page - 1) * limit send(res, 200, all.slice(start, start + limit)) } function handleGetContents(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/contents/${match[3]}`) const record = found.record const filePath = decodeURIComponent(match[3]) const text = contents.get(`${found.key}:${filePath}`) if (text === undefined) return send(res, 404, { message: 'Not Found' }) const sha = createHash('sha1').update(text).digest('hex') send(res, 200, { name: filePath.split('/').pop(), path: filePath, sha, encoding: 'base64', content: Buffer.from(text, 'utf8').toString('base64'), html_url: `${baseUrl}/${record.full_name}/src/branch/${record.default_branch}/${filePath}`, }) } function handleWriteContents(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, `/contents/${match[3]}`) const record = found.record const filePath = decodeURIComponent(match[3]) const contentKey = `${found.key}:${filePath}` const existed = contents.has(contentKey) const decoded = Buffer.from(body?.content || '', 'base64').toString('utf8') contents.set(contentKey, decoded) const sha = createHash('sha1').update(decoded).digest('hex') send(res, existed ? 200 : 201, { content: { name: filePath.split('/').pop(), path: filePath, sha, encoding: 'base64', content: body?.content || '', }, commit: { sha, html_url: `${baseUrl}/${record.full_name}/commit/${sha}` }, }) } function handleGetOrg(match, searchParams, body, headers, res) { const org = orgs.get(match[1].toLowerCase()) if (!org) return send(res, 404, { message: 'Not Found' }) send(res, 200, { id: org.id, username: org.username, full_name: org.full_name, visibility: org.visibility }) } function handleCreateOrg(match, searchParams, body, headers, res) { const username = body?.username if (!username) return send(res, 422, { message: 'username erforderlich' }) if (orgs.has(username.toLowerCase())) { return send(res, 422, { message: `user already exists [name: ${username}]` }) } const org = createOrgRecord(username, body?.full_name, body?.visibility) send(res, 201, { id: org.id, username: org.username, full_name: org.full_name, visibility: org.visibility }) } function handleOrgTeams(match, searchParams, body, headers, res) { const org = orgs.get(match[1].toLowerCase()) if (!org) return send(res, 404, { message: 'Not Found' }) const team = teams.get(org.ownersTeamId) send(res, 200, [{ id: team.id, name: team.name }]) } function handleTeamMembers(match, searchParams, body, headers, res) { const team = teams.get(Number(match[1])) if (!team) return send(res, 404, { message: 'Not Found' }) send(res, 200, team.members.map((login) => ({ login }))) } function handlePutTeamMember(match, searchParams, body, headers, res) { const team = teams.get(Number(match[1])) if (!team) return send(res, 404, { message: 'Not Found' }) const user = match[2] if (!team.members.some((m) => m.toLowerCase() === user.toLowerCase())) { team.members.push(user) } send(res, 204) } function handleGetOrgHooks(match, searchParams, body, headers, res) { const org = orgs.get(match[1].toLowerCase()) if (!org) return send(res, 404, { message: 'Not Found' }) send(res, 200, orgHooks.get(org.username.toLowerCase()) || []) } function handlePostOrgHooks(match, searchParams, body, headers, res) { const org = orgs.get(match[1].toLowerCase()) if (!org) return send(res, 404, { message: 'Not Found' }) const key = org.username.toLowerCase() const list = orgHooks.get(key) || [] const hook = { id: nextHookId++, ...body } list.push(hook) orgHooks.set(key, list) send(res, 201, hook) } const ROUTES = [ { method: 'GET', re: /^\/api\/v1\/user$/, handler: handleGetUser }, { method: 'GET', re: /^\/api\/v1\/repos\/search$/, handler: handleSearchRepos }, { method: 'POST', re: /^\/api\/v1\/orgs$/, handler: handleCreateOrg }, { method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)\/teams$/, handler: handleOrgTeams }, { method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)\/hooks$/, handler: handleGetOrgHooks }, { method: 'POST', re: /^\/api\/v1\/orgs\/([^/]+)\/hooks$/, handler: handlePostOrgHooks }, { method: 'GET', re: /^\/api\/v1\/orgs\/([^/]+)$/, handler: handleGetOrg }, { method: 'GET', re: /^\/api\/v1\/teams\/([^/]+)\/members$/, handler: handleTeamMembers }, { method: 'PUT', re: /^\/api\/v1\/teams\/([^/]+)\/members\/([^/]+)$/, handler: handlePutTeamMember }, { method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/transfer$/, handler: handleTransfer }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks }, { method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents }, { method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents }, { method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/, handler: handleGetRepo }, { method: 'PATCH', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/, handler: handlePatchRepo }, ] /* --------------------------------------------------------- Server --- */ const sockets = new Set() const server = http.createServer((req, res) => { const chunks = [] req.on('data', (c) => chunks.push(c)) req.on('end', async () => { const raw = Buffer.concat(chunks).toString('utf8') const url = new URL(req.url, 'http://fake-gitea.local') let parsedBody = null if (raw) { try { parsedBody = JSON.parse(raw) } catch { parsedBody = null } } calls.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, body: parsedBody, }) if (responseDelayMs > 0) await sleep(responseDelayMs) const failIdx = pendingFailures.findIndex( (f) => f.method === req.method && url.pathname.includes(f.pathIncludes) ) if (failIdx !== -1) { const [f] = pendingFailures.splice(failIdx, 1) return send(res, f.status, { message: `fakeGitea: erzwungener Fehler (${f.status})` }) } const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname)) if (!route) return send(res, 404, { message: 'Not Found' }) const match = route.re.exec(url.pathname) try { await route.handler(match, url.searchParams, parsedBody, req.headers, res) } catch (err) { send(res, 500, { message: err?.message || 'fakeGitea: interner Fehler' }) } }) }) server.on('connection', (socket) => { sockets.add(socket) socket.on('close', () => sockets.delete(socket)) if (isDown) socket.destroy() }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const { port } = server.address() const baseUrl = `http://127.0.0.1:${port}` /* --------------------------------------------------------- Seeding --- */ for (const def of options.repos || []) addRepoInternal(def) for (const [fullName, list] of Object.entries(options.hooks || {})) { hooksByRepo.set(keyFrom(fullName), (list || []).map(normalizeHook)) } for (const [fullName, feed] of Object.entries(options.feeds || {})) { feedsByRepo.set(keyFrom(fullName), feed || []) } for (const [seedKey, text] of Object.entries(options.contents || {})) { const idx = seedKey.indexOf(':') const fullName = idx === -1 ? seedKey : seedKey.slice(0, idx) const filePath = idx === -1 ? '' : seedKey.slice(idx + 1) contents.set(`${keyFrom(fullName)}:${filePath}`, text) } for (const name of options.orgs || []) createOrgRecord(name) for (const [from, to] of Object.entries(options.redirects || {})) { redirects.set(keyFrom(from), to) } /* ------------------------------------------------------ oeffentlich --- */ function repo(fullName) { const record = repos.get(keyFrom(fullName)) return record ? serializeRepo(record) : undefined } function addRepo(def) { return serializeRepo(addRepoInternal(def)) } function hooksOf(fullName) { return (hooksByRepo.get(keyFrom(fullName)) || []).map(serializeHook) } function setDown(down) { isDown = Boolean(down) if (isDown) { for (const socket of sockets) socket.destroy() } } function setTransferDelay(ms) { transferDelayMs = Number(ms) || 0 } /** Verzoegert JEDE Antwort um `ms` (z.B. um einen `GITEA_TIMEOUT_MS`-Abbruch zu provozieren). */ function setResponseDelay(ms) { responseDelayMs = Number(ms) || 0 } /** Laesst den naechsten Transfer-Aufruf 201 mit UNVERAENDERTEM full_name melden (kein Umzug). */ function setTransferNoop() { transferNoopNext = true } function failNext({ method, pathIncludes, status }) { pendingFailures.push({ method, pathIncludes, status }) } function close() { for (const socket of sockets) socket.destroy() return new Promise((resolve) => server.close(() => resolve())) } return { baseUrl, calls, repo, addRepo, hooksOf, setDown, setTransferDelay, setResponseDelay, setTransferNoop, failNext, close, } }