diff --git a/server/config.js b/server/config.js index ecb682c..2dd2c8b 100644 --- a/server/config.js +++ b/server/config.js @@ -235,6 +235,20 @@ export const config = { templateOwner: process.env.GITEA_TEMPLATE_OWNER || 'knso', templateRepo: process.env.GITEA_TEMPLATE_REPO || 'webklar-preview-template', defaultOwner: process.env.GITEA_DEFAULT_OWNER || 'knso', + // Ziel-Organisation des Umzugs (siehe docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md) + org: process.env.GITEA_ORG || 'WEBklar', + // Login des System-Kontos, das automatische Commits/Pushes ausfuehrt (kein Arbeitsblatt dafuer) + systemUser: process.env.GITEA_SYSTEM_USER || 'webklar-system', + // Interne Repos, die trotz Zuweisung nicht nach GITEA_ORG umziehen + keepInPlace: ( + process.env.GITEA_KEEP_IN_PLACE || + 'ANDJ/Emailsorter,ANDJ/Steuerungstechnik,JUSN/tickte-system,JUSN/Webklar,knso/eship,knso/Webklar-Kundenbereich' + ) + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + // Beim Zuweisen eines Projekts werden Pushes der letzten so-vielen Tage nachgetragen + backfillDays: Number(process.env.GITEA_BACKFILL_DAYS) || 30, }, cors: { diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index bb908df..8e1d9e7 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -13,53 +13,8 @@ import { syncPreviewTls, fetchPreviewConfig, } from './previewDeploy.js' - -function giteaHeaders() { - if (!config.gitea.apiToken) { - throw new Error('GITEA_API_TOKEN fehlt in .env') - } - return { - Authorization: `token ${config.gitea.apiToken}`, - 'Content-Type': 'application/json', - } -} - -async function giteaFetch(path, options = {}) { - const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, { - ...options, - headers: { ...giteaHeaders(), ...options.headers }, - }) - - const text = await response.text() - let data = null - if (text) { - try { - data = JSON.parse(text) - } catch { - data = { message: text } - } - } - - if (!response.ok) { - let message = data?.message || `Gitea ${response.status}` - if (response.status === 409 && /same name already exists/i.test(message)) { - message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' - } - const error = new Error(message) - error.status = response.status - throw error - } - - return data -} - -function slugify(value) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 100) -} +import { giteaFetch } from './giteaClient.js' +import { slugify } from './slug.js' export async function listAllAccessibleRepos() { const repos = [] diff --git a/server/services/giteaClient.js b/server/services/giteaClient.js new file mode 100644 index 0000000..239b73d --- /dev/null +++ b/server/services/giteaClient.js @@ -0,0 +1,57 @@ +import { config } from '../config.js' + +/** + * Baut die Authorization-/Content-Type-Header fuer einen Gitea-API-Aufruf. + * Wirft, wenn GITEA_API_TOKEN nicht gesetzt ist. + */ +function giteaHeaders() { + if (!config.gitea.apiToken) { + throw new Error('GITEA_API_TOKEN fehlt in .env') + } + return { + Authorization: `token ${config.gitea.apiToken}`, + 'Content-Type': 'application/json', + } +} + +/** + * Ruft `${config.gitea.baseUrl}/api/v1` auf und liefert die geparste JSON-Antwort + * (oder null ohne Body). Eine Nicht-2xx-Antwort wirft eine Error mit .status; ein 409 + * "same name already exists" wird in die deutsche Meldung uebersetzt (Subdomain-/Repo-Name). + */ +export async function giteaFetch(path, options = {}) { + const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, { + ...options, + headers: { ...giteaHeaders(), ...options.headers }, + }) + + const text = await response.text() + let data = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = { message: text } + } + } + + if (!response.ok) { + let message = data?.message || `Gitea ${response.status}` + if (response.status === 409 && /same name already exists/i.test(message)) { + message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' + } + const error = new Error(message) + error.status = response.status + throw error + } + + return data +} + +/** + * Zerlegt "owner/repo" in [owner, repo]. Reines String-Splitting ohne Validierung - + * fuer nicht "owner/repo"-foermige Eingaben ist das Ergebnis entsprechend kurz/lang. + */ +export function splitRepo(fullName) { + return String(fullName || '').split('/') +} diff --git a/test/giteaClient.test.js b/test/giteaClient.test.js new file mode 100644 index 0000000..5ea994a --- /dev/null +++ b/test/giteaClient.test.js @@ -0,0 +1,39 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { startFakeGitea } from './helpers/fakeGitea.js' + +let fake +let giteaFetch +let config + +before(async () => { + fake = await startFakeGitea({ repos: [{ full_name: 'JUSN/D-ner-Nation' }] }) + Object.assign(process.env, { + GITEA_BASE_URL: fake.baseUrl, + GITEA_API_TOKEN: 'test-token', + }) + // config.js liest die Umgebung beim Import + ;({ giteaFetch } = await import('../server/services/giteaClient.js')) + ;({ config } = await import('../server/config.js')) +}) + +after(async () => { + await fake.close() +}) + +test('sendet den Token', async () => { + await giteaFetch('/repos/JUSN/D-ner-Nation') + assert.equal(fake.calls.at(-1).headers.authorization, 'token test-token') +}) + +test('404 hat status', async () => { + await assert.rejects(giteaFetch('/repos/x/y'), (e) => e.status === 404) +}) + +test('Voreinstellungen der Konfiguration', () => { + assert.equal(config.gitea.org, 'WEBklar') + assert.equal(config.gitea.systemUser, 'webklar-system') + assert.equal(config.gitea.backfillDays, 30) + assert.ok(config.gitea.keepInPlace.includes('JUSN/tickte-system')) + assert.equal(config.gitea.keepInPlace.length, 6) +}) diff --git a/test/helpers/fakeAppwrite.js b/test/helpers/fakeAppwrite.js new file mode 100644 index 0000000..6e7f625 --- /dev/null +++ b/test/helpers/fakeAppwrite.js @@ -0,0 +1,237 @@ +import http from 'node:http' +import { randomUUID } from 'node:crypto' + +/** + * Nachgebautes Appwrite fuer Tests: nur der REST-Ausschnitt, den `appwriteAdmin.js` und + * `appwriteStaff.js` nutzen (Dokumente einer Sammlung, `/account` per JWT). Laeuft auf + * 127.0.0.1 mit einem vom Betriebssystem vergebenen Port; `endpoint` endet auf `/v1`, + * genau wie `config.appwrite.endpoint`. + * + * @param {object} [options] + * @param {Record} [options.seed] + * Anfangsbestand je Sammlung (Sammlungsname -> Dokumente). Jedes Dokument darf `$id` + * mitbringen (sonst wird eins erzeugt); `$createdAt`/`$updatedAt` werden sonst mit dem + * Startzeitpunkt gefuellt. + * @returns {Promise<{ + * endpoint: string, + * all: (collection: string) => object[], + * get: (collection: string, id: string) => (object|undefined), + * calls: Array<{method: string, path: string, query: Record, headers: Record, body: any}>, + * close: () => Promise, + * }>} `all`/`get` lesen den LIVE-Stand (nach create/patch/delete ueber HTTP). Die + * Datenbank-ID im Pfad wird nicht geprueft - jede wird akzeptiert. + */ +export async function startFakeAppwrite(options = {}) { + const calls = [] + /** @type {Map>} Sammlungsname -> (Dokument-ID -> Dokument). */ + const store = new Map() + + function collectionOf(name) { + let map = store.get(name) + if (!map) { + map = new Map() + store.set(name, map) + } + return map + } + + function all(col) { + return [...collectionOf(col).values()] + } + + function get(col, id) { + return collectionOf(col).get(id) + } + + 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 parseQueries(searchParams) { + return searchParams + .getAll('queries[]') + .map((s) => { + try { + return JSON.parse(s) + } catch { + return null + } + }) + .filter(Boolean) + } + + /* ------------------------------------------------------------- Routen --- */ + + function handleAccount(match, searchParams, body, headers, res) { + if (headers['x-appwrite-jwt'] === 'jwt-admin') { + return send(res, 200, { $id: 'staff1', labels: ['admin'] }) + } + send(res, 401, { message: 'Unauthorized' }) + } + + function handleListDocuments(match, searchParams, body, headers, res) { + const docs = [...collectionOf(match[2]).values()] + const queries = parseQueries(searchParams) + + let result = docs.filter((doc) => + queries.every((q) => { + if (q.method === 'equal') return q.values.includes(doc[q.attribute]) + if (q.method === 'greaterThan') return doc[q.attribute] > q.values[0] + return true + }) + ) + + const orderAsc = queries.find((q) => q.method === 'orderAsc') + const orderDesc = queries.find((q) => q.method === 'orderDesc') + const sortAttr = orderAsc?.attribute || orderDesc?.attribute + if (sortAttr) { + const dir = orderAsc ? 1 : -1 + result = [...result].sort((a, b) => { + if (a[sortAttr] === b[sortAttr]) return 0 + return a[sortAttr] > b[sortAttr] ? dir : -dir + }) + } + + const total = result.length + const limit = queries.find((q) => q.method === 'limit') + if (limit) result = result.slice(0, Number(limit.values[0])) + + send(res, 200, { total, documents: result }) + } + + function handleCreateDocument(match, searchParams, body, headers, res) { + const map = collectionOf(match[2]) + const documentId = body?.documentId || randomUUID() + if (map.has(documentId)) { + return send(res, 409, { message: 'Document with the requested ID already exists.' }) + } + const now = new Date().toISOString() + const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) } + map.set(documentId, doc) + send(res, 201, doc) + } + + function handleGetDocument(match, searchParams, body, headers, res) { + const doc = collectionOf(match[2]).get(match[3]) + if (!doc) return send(res, 404, { message: 'Document not found' }) + send(res, 200, doc) + } + + function handlePatchDocument(match, searchParams, body, headers, res) { + const doc = collectionOf(match[2]).get(match[3]) + if (!doc) return send(res, 404, { message: 'Document not found' }) + Object.assign(doc, body?.data || {}) + doc.$updatedAt = new Date().toISOString() + send(res, 200, doc) + } + + function handleDeleteDocument(match, searchParams, body, headers, res) { + const map = collectionOf(match[2]) + if (!map.has(match[3])) return send(res, 404, { message: 'Document not found' }) + map.delete(match[3]) + send(res, 204) + } + + const ROUTES = [ + { method: 'GET', re: /^\/v1\/account$/, handler: handleAccount }, + { + method: 'GET', + re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents$/, + handler: handleListDocuments, + }, + { + method: 'POST', + re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents$/, + handler: handleCreateDocument, + }, + { + method: 'GET', + re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/, + handler: handleGetDocument, + }, + { + method: 'PATCH', + re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/, + handler: handlePatchDocument, + }, + { + method: 'DELETE', + re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/, + handler: handleDeleteDocument, + }, + ] + + /* --------------------------------------------------------- Server --- */ + + const sockets = new Set() + const server = http.createServer((req, res) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8') + const url = new URL(req.url, 'http://fake-appwrite.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 { + route.handler(match, url.searchParams, parsedBody, req.headers, res) + } catch (err) { + send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' }) + } + }) + }) + + server.on('connection', (socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() + const endpoint = `http://127.0.0.1:${port}/v1` + + /* --------------------------------------------------------- Seeding --- */ + + const seededAt = new Date().toISOString() + for (const [col, docs] of Object.entries(options.seed || {})) { + const map = collectionOf(col) + for (const doc of docs || []) { + const { $id, $createdAt, $updatedAt, ...fields } = doc + const id = $id || randomUUID() + map.set(id, { $id: id, $createdAt: $createdAt || seededAt, $updatedAt: $updatedAt || seededAt, ...fields }) + } + } + + function close() { + for (const socket of sockets) socket.destroy() + return new Promise((resolve) => server.close(() => resolve())) + } + + return { endpoint, all, get, calls, close } +} diff --git a/test/helpers/fakeGitea.js b/test/helpers/fakeGitea.js new file mode 100644 index 0000000..aca00eb --- /dev/null +++ b/test/helpers/fakeGitea.js @@ -0,0 +1,475 @@ +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 } +}