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. * * @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"`. * @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 {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, * close: () => Promise, * }>} `calls` waechst live, in der Reihenfolge, in der die Anfragen eintreffen - VOR einer * etwaigen `setTransferDelay`-Verzoegerung der Antwort. `repo()`/`hooksOf()` lesen den * aktuellen Stand direkt (ohne Weiterleitung ueber einen umgezogenen alten Namen). */ export async function startFakeGitea(options = {}) { const calls = [] let isDown = false let transferDelayMs = 0 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, } repos.set(keyFrom(fullName), record) return record } function normalizeHook(h) { return { id: h.id, url: h.url, events: h.events || ['push'], active: h.active !== undefined ? h.active : true, type: h.type || 'gitea', config: { url: h.url, content_type: 'json', ...(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) }) } 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 send304(res, found.redirectTo) } send(res, 200, serializeRepo(found.record)) } function send304(res, newFullName) { res.writeHead(307, { Location: `${baseUrl}/api/v1/repos/${newFullName}` }) res.end() } 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 send304(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 key = keyFrom(match[1], match[2]) const record = repos.get(key) if (!record) return send(res, 404, { message: 'Not Found' }) 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 key = keyFrom(match[1], match[2]) if (!repos.has(key)) return send(res, 404, { message: 'Not Found' }) send(res, 200, hooksByRepo.get(key) || []) } function handleDeleteRepoHook(match, searchParams, body, headers, res) { const key = keyFrom(match[1], match[2]) const list = hooksByRepo.get(key) if (!repos.has(key) || !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 key = keyFrom(match[1], match[2]) if (!repos.has(key)) return send(res, 404, { message: 'Not Found' }) const all = feedsByRepo.get(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 key = keyFrom(match[1], match[2]) const record = repos.get(key) if (!record) return send(res, 404, { message: 'Not Found' }) const filePath = decodeURIComponent(match[3]) const text = contents.get(`${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 key = keyFrom(match[1], match[2]) const record = repos.get(key) if (!record) return send(res, 404, { message: 'Not Found' }) const filePath = decodeURIComponent(match[3]) const contentKey = `${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, }) 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) /* ------------------------------------------------------ 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((h) => ({ ...h, config: { ...h.config } })) } function setDown(down) { isDown = Boolean(down) if (isDown) { for (const socket of sockets) socket.destroy() } } function setTransferDelay(ms) { transferDelayMs = Number(ms) || 0 } function close() { for (const socket of sockets) socket.destroy() return new Promise((resolve) => server.close(() => resolve())) } return { baseUrl, calls, repo, addRepo, hooksOf, setDown, setTransferDelay, close } }