Umzug: Webhook-Adresse aus config.url, Aufräumen auch nach Selbstheilung

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 10:21:34 +00:00
1 parent 6e6c9a4c24
commit 405b2a0485
4 files changed
+277 -88

No files matched your search

+18 -1
View File
@@ -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<string, string>, headers: Record<string, string>, body: any}>,
* failNext: (spec: {method: string, pathIncludes: string, status: number}) => void,
* close: () => Promise<void>,
* }>} `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<string, Map<string, object>>} 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 }
}
+80 -39
View File
@@ -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/<alt>` -> `301`, `location: /api/v1/repos/<neu>`).
*
* @param {object} [options]
* @param {Array<{full_name: string, archived?: boolean, private?: boolean, description?: string, default_branch?: string}>} [options.repos]
* Anfangsbestand an Repos.
* @param {Record<string, Array<{id: number|string, url: string, events?: string[], active?: boolean, type?: string}>>} [options.hooks]
* Repo-Webhooks je `"owner/repo"`.
* @param {Record<string, Array<{id: number|string, url?: string, config?: {url?: string, content_type?: string}, events?: string[], active?: boolean, type?: string}>>} [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<string, object[]>} [options.feeds]
* Aktivitaeten-Feed je `"owner/repo"`, neueste zuerst (wie `GET .../activities/feeds` sie liefert).
* @param {Record<string, string>} [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<void>,
* }>} `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 }
}
+58 -4
View File
@@ -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)