From 405b2a0485ecba11168fd3cf9ff5ccb91996ff47 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:11:55 +0000 Subject: [PATCH] =?UTF-8?q?Umzug:=20Webhook-Adresse=20aus=20config.url,=20?= =?UTF-8?q?Aufr=C3=A4umen=20auch=20nach=20Selbstheilung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round 1 (Review C1/I1/I2/I3, Controller-Ruling R11): - C1 (kritisch): Hooks haben in echtem Gitea kein Top-Level-url, nur config.url - isInternalRepo/deletePortalHooks lasen bisher ein Feld, das es in Produktion nicht gibt. - I1: Schritt-2-Namensvergleich jetzt ohne Ruecksicht auf Gross-/Kleinschreibung. - I2: fs.rm in Schritt 9 gegen EACCES/EBUSY/ENOTEMPTY & Co. abgesichert. - I3: Aufraeumen (Cache, alter Einzel-Webhook, Preview-Verzeichnis) laeuft jetzt ueber den neuen idempotenten finishMove()-Baustein in jedem Fall, in dem das Repo tatsaechlich in WEBklar liegt - auch nach einem Appwrite-Fehler, bei Selbstheilung und bei einem schon dort liegenden Repo (WARN.hookNotRemoved bei fehlgeschlagenem Loeschen). - Fake Gitea: Hook-Form ohne Top-Level-url, 301 mit relativer Location (auch fuer Unterpfade) wie im echten Gitea 1.25.2, failNext() fuer gezielte Fehler in Tests. Co-Authored-By: Claude Opus 5.5 --- server/services/repoMove.js | 165 +++++++++++++++++++++++++---------- test/helpers/fakeAppwrite.js | 19 +++- test/helpers/fakeGitea.js | 119 ++++++++++++++++--------- test/repoMove.test.js | 62 ++++++++++++- 4 files changed, 277 insertions(+), 88 deletions(-) diff --git a/server/services/repoMove.js b/server/services/repoMove.js index a4637b5..f70b242 100644 --- a/server/services/repoMove.js +++ b/server/services/repoMove.js @@ -6,7 +6,7 @@ import { listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { invalidateProjectCache } from './projectInsights.js' import { previewWorkDir } from './previewDeploy.js' -/** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints"). */ +/** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints", und Controller-Ruling R11). */ export const WARN = { internal: 'Internes Repo, bleibt wo es ist.', nameTaken: (name) => @@ -15,6 +15,8 @@ export const WARN = { `Gitea ist gerade nicht erreichbar (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`, docNotUpdated: (reason) => `Das Repo liegt jetzt in WEBklar, aber die Projektdaten konnten nicht angepasst werden (${reason}). Bitte ‚Pushes nachtragen‘ klicken.`, + hookNotRemoved: (reason) => + `Der alte Einzel-Webhook ans Portal konnte nicht entfernt werden (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`, } /** Portal-Webhook-URL: ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL exakt damit beginnt. */ @@ -22,6 +24,16 @@ function portalWebhookUrl() { return `${config.publicBaseUrl}/webhook/gitea` } +/** + * Ziel-URL eines Gitea-Hooks. Echtes Gitea (Produktions-Swagger von 1.25.2, `definitions.Hook`) + * hat dafuer KEIN Top-Level-`url`-Feld - das Ziel steht ausschliesslich unter `config.url`. Ein + * Top-Level-`url` zu lesen wuerde in Produktion immer `''` liefern und jedes Repo mit irgendeinem + * Hook faelschlich als intern einstufen (siehe Review C1). + */ +function hookUrl(hook) { + return hook?.config?.url || '' +} + /** true, wenn `owner/repo` (ohne Ruecksicht auf Gross-/Kleinschreibung) in der Ziel-Organisation liegt. */ export function isOrgRepo(repoFullName) { const [owner] = splitRepo(repoFullName) @@ -33,6 +45,11 @@ export function repoLockKey(repoFullName) { return 'repo:' + repoFullName.toLowerCase() } +/** owner/repo ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Gitea haelt es ebenso). */ +function sameRepoFullName(a, b) { + return String(a || '').toLowerCase() === String(b || '').toLowerCase() +} + /** * true, wenn das Repo in `GITEA_KEEP_IN_PLACE` steht (ohne Ruecksicht auf Gross-/Kleinschreibung) * oder einen Webhook hat, dessen URL nicht mit der Portal-Webhook-URL beginnt (Deploy-verdrahtetes @@ -45,7 +62,7 @@ export async function isInternalRepo(repoFullName) { const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) const portalUrl = portalWebhookUrl() - return (hooks || []).some((hook) => !String(hook.url || '').startsWith(portalUrl)) + return (hooks || []).some((hook) => !hookUrl(hook).startsWith(portalUrl)) } /** @@ -95,56 +112,66 @@ async function updateProjectDocument(project, to) { }) } -/** Schritt 8: alte Portal-Webhooks am neuen Ort loeschen. Fehler werden nur geloggt. */ -async function deletePortalHooks(to) { +/** + * Loescht die alten Einzel-Webhooks ans Portal an einem (aktuellen) Repo-Ort - die Organisation + * hat inzwischen ihren eigenen Webhook. Ein fehlgeschlagenes Listen wird nur geloggt (kein + * Repo-Zustand haengt daran); ein fehlgeschlagenes Loeschen liefert eine Warnung (R11), damit + * "Pushes nachtragen" es beim naechsten Versuch erneut probieren kann - sonst bleibt der alte + * Webhook fuer immer bestehen und jeder Push wird doppelt zugestellt (siehe Review I3). + */ +async function deletePortalHooks(repoFullName) { const portalUrl = portalWebhookUrl() let hooks try { - hooks = await giteaFetch(`/repos/${to}/hooks`) + hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) } catch (err) { - console.error(`[repoMove] Webhooks von ${to} konnten nicht gelesen werden:`, err.message) - return + console.error(`[repoMove] Webhooks von ${repoFullName} konnten nicht gelesen werden:`, err.message) + return [] } + const warnings = [] for (const hook of hooks || []) { - if (!String(hook.url || '').startsWith(portalUrl)) continue + if (!hookUrl(hook).startsWith(portalUrl)) continue try { - await giteaFetch(`/repos/${to}/hooks/${hook.id}`, { method: 'DELETE' }) + await giteaFetch(`/repos/${repoFullName}/hooks/${hook.id}`, { method: 'DELETE' }) } catch (err) { - console.error(`[repoMove] Webhook ${hook.id} bei ${to} konnte nicht geloescht werden:`, err.message) + console.error(`[repoMove] Webhook ${hook.id} bei ${repoFullName} konnte nicht geloescht werden:`, err.message) + warnings.push(WARN.hookNotRemoved(err.message)) } } + return warnings } /** - * Verschiebt das Repo eines zugewiesenen Projekts nach `config.gitea.org`, wenn noetig, und haelt - * Projektdokument, Cache und Webhooks auf dem Laufenden. Wirft nie wegen eines Gitea- oder - * Appwrite-Problems (siehe `classifyGiteaError`/`docNotUpdated`) - Task 6 verlaesst sich darauf, - * dass eine Zuweisung auch bei einem Ausfall bestehen bleibt. + * Schritte 7-9, idempotent: Cache fuer alten und neuen Namen verwerfen, alte Portal-Webhooks am + * neuen Ort loeschen, Preview-Arbeitsverzeichnis des alten Namens aufraeumen. Laeuft unabhaengig + * davon, ob das Projektdokument selbst erfolgreich aktualisiert wurde (R11) - der Repo-Zustand in + * Gitea ist in jedem Fall schon "in WEBklar", und genau das muss aufgeraeumt werden. `fs.rm` ist + * abgesichert (Review I2): ein EACCES/EBUSY/ENOTEMPTY & Co. nach dem schon erfolgten, nicht mehr + * rueckgaengig zu machenden Transfer darf `moveRepoToOrg` nicht mehr zum Werfen bringen. */ -export async function moveRepoToOrg(project) { - const from = project.repoFullName +async function finishMove(from, to) { + invalidateProjectCache(from) + invalidateProjectCache(to) - // Schritt 1: schon in der Organisation. - if (isOrgRepo(from)) return { moved: null, warnings: [] } + const warnings = await deletePortalHooks(to) - // Schritt 2: aktuellen Stand holen (fetch folgt Giteas 307-Weiterleitung von Hand verschobener Repos). - let current try { - current = await giteaFetch(`/repos/${from}`) + await fs.rm(previewWorkDir(from), { recursive: true, force: true }) } catch (err) { - return classifyGiteaError(err) - } - if (current.full_name !== from) { - const to = current.full_name - try { - await updateProjectDocument(project, to) - } catch (err) { - return { moved: { from, to }, warnings: [WARN.docNotUpdated(err.message)] } - } - return { moved: { from, to }, warnings: [] } + console.error(`[repoMove] Preview-Arbeitsverzeichnis von ${from} konnte nicht entfernt werden:`, err.message) } + return warnings +} + +/** + * Schritte 3-6 (+ Aufraeumen): internes Repo bleibt, Namenskonflikt wird gemeldet, sonst Transfer + * + Dokument-Update unter Sperre, danach `finishMove` - unabhaengig davon, ob `moveRepoToOrg` hier + * ueber den normalen Weg ankommt oder ueber die Selbstheilung mit dem kanonischen, aber + * Nicht-WEBklar-Namen als `from`. + */ +async function moveViaTransfer(project, from) { // Schritt 3: internes (Deploy-verdrahtetes) Repo bleibt, wo es ist. let internal try { @@ -180,29 +207,79 @@ export async function moveRepoToOrg(project) { } const confirmedTo = transferResult.full_name + let docErr = null try { await updateProjectDocument(project, confirmedTo) } catch (err) { - return { failed: 'doc', to: confirmedTo, err } + docErr = err } - return { to: confirmedTo } + return { to: confirmedTo, docErr } }) if (outcome.failed === 'gitea') return classifyGiteaError(outcome.err) - if (outcome.failed === 'doc') { - return { moved: { from, to: outcome.to }, warnings: [WARN.docNotUpdated(outcome.err.message)] } - } + const confirmedTo = outcome.to + // Das Aufraeumen (Cache, alter Webhook, Preview-Verzeichnis) haengt nicht am Dokument-Update - + // das Repo liegt in Gitea so oder so schon in WEBklar (R11). + const finishWarnings = await finishMove(from, confirmedTo) + const warnings = outcome.docErr + ? [WARN.docNotUpdated(outcome.docErr.message), ...finishWarnings] + : finishWarnings + return { moved: { from, to: confirmedTo }, warnings } +} - // Schritt 7: Cache fuer alten und neuen Namen verwerfen. - invalidateProjectCache(from) - invalidateProjectCache(confirmedTo) +/** + * Verschiebt das Repo eines zugewiesenen Projekts nach `config.gitea.org`, wenn noetig, und haelt + * Projektdokument, Cache und Webhooks auf dem Laufenden. Wirft nie wegen eines Gitea- oder + * Appwrite-Problems (siehe `classifyGiteaError`/`docNotUpdated`) - Task 6 verlaesst sich darauf, + * dass eine Zuweisung auch bei einem Ausfall bestehen bleibt. + */ +export async function moveRepoToOrg(project) { + const from = project.repoFullName - // Schritt 8: alten Portal-Webhook am neuen Ort loeschen (die Organisation hat einen eigenen). - await deletePortalHooks(confirmedTo) + // Schritt 1: schon in der Organisation - nur noch nach einem liegen gebliebenen Einzel-Webhook + // schauen (R11), damit "Pushes nachtragen" auch das noch nachholen kann. + if (isOrgRepo(from)) { + const warnings = await deletePortalHooks(from) + return { moved: null, warnings } + } - // Schritt 9: Preview-Arbeitsverzeichnis des alten Namens aufraeumen. - await fs.rm(previewWorkDir(from), { recursive: true, force: true }) + // Schritt 2: aktuellen Stand holen (fetch folgt Giteas 301-Weiterleitung von Hand verschobener + // Repos). Der Vergleich ist ohne Ruecksicht auf Gross-/Kleinschreibung (Review I1) - Gitea + // liefert sonst z. B. "JUSN/Case-Test" fuer den gespeicherten Namen "jusn/case-test", ohne dass + // sich am Repo tatsaechlich etwas geaendert haette. + let current + try { + current = await giteaFetch(`/repos/${from}`) + } catch (err) { + return classifyGiteaError(err) + } - return { moved: { from, to: confirmedTo }, warnings: [] } + if (!sameRepoFullName(current.full_name, from)) { + // Das Repo wurde ausserhalb des Portals umbenannt/verschoben (z. B. von Hand). Den + // gespeicherten Namen dafuer korrigieren - unter derselben Sperre wie ein regulaerer Umzug. + const canonical = current.full_name + const renameOutcome = await withLocks([repoLockKey(from), repoLockKey(canonical)], async () => { + try { + await updateProjectDocument(project, canonical) + return { ok: true } + } catch (err) { + return { ok: false, err } + } + }) + const renameWarnings = renameOutcome.ok ? [] : [WARN.docNotUpdated(renameOutcome.err.message)] + + if (isOrgRepo(canonical)) { + // Schon in WEBklar (z. B. von Hand verschoben): nur noch aufraeumen. + const finishWarnings = await finishMove(from, canonical) + return { moved: { from, to: canonical }, warnings: [...renameWarnings, ...finishWarnings] } + } + + // Woanders hin verschoben, aber nicht nach WEBklar: mit dem kanonischen Namen als neuem + // Ausgangspunkt tatsaechlich umziehen (Schritte 3-5), statt den Umzug hier abzubrechen. + const result = await moveViaTransfer(project, canonical) + return { moved: result.moved, warnings: [...renameWarnings, ...result.warnings] } + } + + return moveViaTransfer(project, from) } diff --git a/test/helpers/fakeAppwrite.js b/test/helpers/fakeAppwrite.js index 6e7f625..6051145 100644 --- a/test/helpers/fakeAppwrite.js +++ b/test/helpers/fakeAppwrite.js @@ -17,12 +17,17 @@ import { randomUUID } from 'node:crypto' * all: (collection: string) => object[], * get: (collection: string, id: string) => (object|undefined), * calls: Array<{method: string, path: string, query: Record, headers: Record, body: any}>, + * failNext: (spec: {method: string, pathIncludes: string, status: number}) => void, * 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. + * `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). */ export async function startFakeAppwrite(options = {}) { const calls = [] + const pendingFailures = [] /** @type {Map>} Sammlungsname -> (Dokument-ID -> Dokument). */ const store = new Map() @@ -196,6 +201,14 @@ export async function startFakeAppwrite(options = {}) { body: parsedBody, }) + 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: `fakeAppwrite: 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) @@ -228,10 +241,14 @@ export async function startFakeAppwrite(options = {}) { } } + 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 { endpoint, all, get, calls, close } + return { endpoint, all, get, calls, failNext, close } } diff --git a/test/helpers/fakeGitea.js b/test/helpers/fakeGitea.js index 473843e..d360638 100644 --- a/test/helpers/fakeGitea.js +++ b/test/helpers/fakeGitea.js @@ -10,11 +10,19 @@ import { createHash } from 'node:crypto' * 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"`. + * @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] @@ -34,15 +42,21 @@ import { createHash } from 'node:crypto' * hooksOf: (fullName: string) => object[], * setDown: (down: boolean) => void, * setTransferDelay: (ms: number) => 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`-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. */ export async function startFakeGitea(options = {}) { const calls = [] let isDown = false let transferDelayMs = 0 + const pendingFailures = [] let nextRepoId = 1 let nextHookId = 1 @@ -121,17 +135,27 @@ export async function startFakeGitea(options = {}) { 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, - 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 || {}) }, + 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++, @@ -184,26 +208,23 @@ export async function startFakeGitea(options = {}) { 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 send304(res, found.redirectTo) - } + if (found.redirectTo) return sendRepoRedirect(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) - } + if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo) Object.assign(found.record, body || {}) send(res, 200, serializeRepo(found.record)) } @@ -211,9 +232,11 @@ export async function startFakeGitea(options = {}) { 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 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 key = found.key + const record = found.record const newOwner = body?.new_owner if (!newOwner) return send(res, 422, { message: 'new_owner erforderlich' }) @@ -239,15 +262,18 @@ export async function startFakeGitea(options = {}) { } 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) || []) + 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 key = keyFrom(match[1], match[2]) - const list = hooksByRepo.get(key) - if (!repos.has(key) || !list) return send(res, 404, { message: 'Not Found' }) + 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) @@ -255,9 +281,10 @@ export async function startFakeGitea(options = {}) { } 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 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 @@ -265,11 +292,12 @@ export async function startFakeGitea(options = {}) { } 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 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(`${key}:${filePath}`) + 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, { @@ -283,11 +311,12 @@ export async function startFakeGitea(options = {}) { } 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 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 = `${key}:${filePath}` + const contentKey = `${found.key}:${filePath}` const existed = contents.has(contentKey) const decoded = Buffer.from(body?.content || '', 'base64').toString('utf8') contents.set(contentKey, decoded) @@ -406,6 +435,14 @@ export async function startFakeGitea(options = {}) { body: parsedBody, }) + 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) @@ -459,7 +496,7 @@ export async function startFakeGitea(options = {}) { } function hooksOf(fullName) { - return (hooksByRepo.get(keyFrom(fullName)) || []).map((h) => ({ ...h, config: { ...h.config } })) + return (hooksByRepo.get(keyFrom(fullName)) || []).map(serializeHook) } function setDown(down) { @@ -473,10 +510,14 @@ export async function startFakeGitea(options = {}) { transferDelayMs = Number(ms) || 0 } + 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, close } + return { baseUrl, calls, repo, addRepo, hooksOf, setDown, setTransferDelay, failNext, close } } diff --git a/test/repoMove.test.js b/test/repoMove.test.js index 0d332b3..9115d35 100644 --- a/test/repoMove.test.js +++ b/test/repoMove.test.js @@ -28,6 +28,8 @@ function doc(name) { return aw.get('websiteProjects', idByRepo.get(name)) } +const PORTAL_HOOK_URL = 'https://project.webklar.com/webhook/gitea' + before(async () => { gitea = await startFakeGitea({ repos: [ @@ -39,10 +41,18 @@ before(async () => { { full_name: 'JUSN/schon-da' }, { full_name: 'WEBklar/von-hand' }, { full_name: 'ANDJ/unberuehrt' }, + { full_name: 'JUSN/doc-fail-test' }, + { full_name: 'JUSN/verboten' }, + { full_name: 'JUSN/Case-Test' }, ], + // C1: Hooks kommen (wie im echten Gitea) OHNE Top-Level-`url` an - `url` hier ist nur die + // Seed-Abkuerzung, gespeichert/ausgeliefert wird das Ziel ausschliesslich unter `config.url`. hooks: { - 'knso/emmas-schatzkiste': [{ id: 110, url: 'https://project.webklar.com/webhook/gitea' }], + 'knso/emmas-schatzkiste': [{ id: 110, url: PORTAL_HOOK_URL }], 'knso/hytail-ark': [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }], + 'WEBklar/von-hand': [{ id: 210, url: PORTAL_HOOK_URL }], + 'WEBklar/schon-da': [{ id: 310, url: PORTAL_HOOK_URL }], + 'JUSN/doc-fail-test': [{ id: 410, url: PORTAL_HOOK_URL }], }, // knso/von-hand wurde von Hand bereits nach WEBklar/von-hand verschoben (kein Transfer-Aufruf noetig). redirects: { @@ -59,6 +69,9 @@ before(async () => { 'JUSN/schon-da', 'knso/von-hand', // Appwrite kennt noch den alten Namen (siehe Brief) 'ANDJ/unberuehrt', + 'JUSN/doc-fail-test', + 'JUSN/verboten', + 'jusn/case-test', // Appwrite kennt eine andere Schreibweise als Gitea (dort: JUSN/Case-Test) ] const websiteProjects = repoNames.map((repoFullName) => { const $id = randomUUID() @@ -106,11 +119,29 @@ test('verschiebt Repo aus eigenem Konto', async () => { await assert.rejects(fs.access(previewWorkDir('JUSN/D-ner-Nation'))) }) test('löscht den alten Portal-Webhook', async () => { - await moveRepoToOrg(doc('knso/emmas-schatzkiste')) + const r = await moveRepoToOrg(doc('knso/emmas-schatzkiste')) + assert.deepEqual(r.moved, { from: 'knso/emmas-schatzkiste', to: 'WEBklar/emmas-schatzkiste' }) assert.deepEqual(gitea.hooksOf('WEBklar/emmas-schatzkiste'), []) + assert.equal( + gitea.calls.some( + (c) => c.method === 'DELETE' && c.path === '/api/v1/repos/WEBklar/emmas-schatzkiste/hooks/110' + ), + true + ) +}) +test('docNotUpdated: Repo zieht trotzdem um, Portal-Webhook wird trotzdem geloescht', async () => { + aw.failNext({ method: 'PATCH', pathIncludes: doc('JUSN/doc-fail-test').$id, status: 500 }) + const r = await moveRepoToOrg(doc('JUSN/doc-fail-test')) + assert.deepEqual(r.moved, { from: 'JUSN/doc-fail-test', to: 'WEBklar/doc-fail-test' }) + assert.match(r.warnings[0], /^Das Repo liegt jetzt in WEBklar/) + assert.deepEqual(gitea.hooksOf('WEBklar/doc-fail-test'), []) }) test('schon in WEBklar: nichts passiert', async () => { - assert.deepEqual(await moveRepoToOrg(doc('WEBklar/schon-da')), { moved: null, warnings: [] }) + const r = await moveRepoToOrg(doc('WEBklar/schon-da')) + assert.deepEqual(r, { moved: null, warnings: [] }) + // R11: "Pushes nachtragen" raeumt auch bei einem schon in WEBklar liegenden Repo einen liegen + // gebliebenen Einzel-Webhook auf. + assert.deepEqual(gitea.hooksOf('WEBklar/schon-da'), []) }) test('internes Repo per Liste und per fremdem Webhook', async () => { for (const name of ['ANDJ/Steuerungstechnik', 'knso/hytail-ark']) { @@ -122,11 +153,34 @@ test('Name existiert in WEBklar', async () => { const r = await moveRepoToOrg(doc('JUSN/schon-da')) assert.deepEqual(r, { moved: null, warnings: ['In WEBklar gibt es schon ein Repo namens schon-da. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.'] }) }) +test('R10: 403 beim Transfer', async () => { + gitea.failNext({ method: 'POST', pathIncludes: '/transfer', status: 403 }) + const r = await moveRepoToOrg(doc('JUSN/verboten')) + assert.equal(r.moved, null) + assert.match(r.warnings[0], /^Umzug nach WEBklar nicht möglich \(.+\)\.$/) +}) test('Weiterleitung korrigiert Projektdaten', async () => { const r = await moveRepoToOrg(doc('knso/von-hand')) assert.deepEqual(r.moved, { from: 'knso/von-hand', to: 'WEBklar/von-hand' }) assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && c.path.includes('von-hand')).length, 0) - assert.equal(aw.get('websiteProjects', doc('knso/von-hand').$id).repoFullName, 'WEBklar/von-hand') + const d = aw.get('websiteProjects', doc('knso/von-hand').$id) + assert.equal(d.repoFullName, 'WEBklar/von-hand') + assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/von-hand`) + assert.equal(d.giteaRepoName, 'von-hand') + // R11/I3: der beim Hand-Umzug liegen gebliebene Einzel-Webhook wird jetzt mit aufgeraeumt. + assert.deepEqual(gitea.hooksOf('WEBklar/von-hand'), []) +}) +test('nur Groß-/Kleinschreibung unterschiedlich: echter Umzug', async () => { + // I1: gespeichert ist "jusn/case-test", Gitea antwortet auf denselben Namen mit "JUSN/Case-Test" + // (reine Schreibweise, kein tatsaechlicher Umzug) - das darf den Selbstheilungs-Zweig NICHT + // ausloesen, sondern muss ganz normal weiter zum Transfer fuehren. + const r = await moveRepoToOrg(doc('jusn/case-test')) + assert.deepEqual(r, { moved: { from: 'jusn/case-test', to: 'WEBklar/Case-Test' }, warnings: [] }) + assert.equal( + gitea.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/jusn/case-test/transfer'), + true + ) + assert.equal(aw.get('websiteProjects', doc('jusn/case-test').$id).repoFullName, 'WEBklar/Case-Test') }) test('Erkennung ohne Groß-/Kleinschreibung', async () => { assert.equal(isOrgRepo('webklar/x'), true)