Gesamtprüfung: Zeitlimit für Gitea, Aufräumen ohne Weiterleitung

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 febac3c2a3
commit d5c70a5bd6
7 files changed
+233 -11

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { startFakeGitea } from './helpers/fakeGitea.js'
let gitea
let giteaFetch
let moveRepoToOrg
// Eigene Datei, weil GITEA_TIMEOUT_MS hier absichtlich sehr klein gesetzt wird (config.js liest
// die Umgebung beim Import - andere Testdateien duerfen davon nicht betroffen sein, jede
// Testdatei laeuft in einem eigenen Prozess).
before(async () => {
gitea = await startFakeGitea({ repos: [{ full_name: 'knso/langsam' }] })
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'test-token',
GITEA_TIMEOUT_MS: '200',
})
;({ giteaFetch } = await import('../server/services/giteaClient.js'))
;({ moveRepoToOrg } = await import('../server/services/repoMove.js'))
})
after(async () => {
await gitea.close()
})
test('giteaFetch bricht bei Zeitueberschreitung ohne .status ab', async () => {
gitea.setResponseDelay(2000)
const start = Date.now()
await assert.rejects(
giteaFetch('/repos/knso/langsam'),
(err) => {
assert.equal(err.status, undefined)
assert.match(err.message, /Zeitlimit/)
return true
}
)
assert.ok(Date.now() - start < 2000, 'giteaFetch haette nach ~200ms abbrechen muessen')
})
test('moveRepoToOrg meldet "Gitea ist gerade nicht erreichbar" statt zu haengen', async () => {
gitea.setResponseDelay(2000)
const start = Date.now()
const r = await moveRepoToOrg({ $id: 'x', repoFullName: 'knso/langsam' })
assert.ok(Date.now() - start < 2000, 'moveRepoToOrg haette nicht haengen bleiben duerfen')
assert.equal(r.moved, null)
assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar \(/)
})
+43 -4
View File
@@ -42,20 +42,27 @@ import { createHash } from 'node:crypto'
* hooksOf: (fullName: string) => object[],
* setDown: (down: boolean) => void,
* setTransferDelay: (ms: number) => void,
* setResponseDelay: (ms: number) => void,
* setTransferNoop: () => void,
* failNext: (spec: {method: string, pathIncludes: string, status: number}) => void,
* close: () => Promise<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).
* etwaigen `setTransferDelay`/`setResponseDelay`-Verzoegerung der Antwort. `repo()`/`hooksOf()`
* lesen den aktuellen Stand direkt (ohne Weiterleitung ueber einen umgezogenen alten Namen).
* `failNext({method, pathIncludes, status})` laesst GENAU die naechste Anfrage, deren Methode
* passt und deren Pfad `pathIncludes` enthaelt, mit diesem Status und einer generischen
* JSON-Fehlermeldung scheitern (danach wieder normales Verhalten) - fuer Tests, die einen
* einzelnen Aufruf gezielt fehlschlagen lassen wollen, ohne den ganzen Server abzuschalten.
* `setResponseDelay(ms)` verzoegert JEDE Antwort um `ms` (z.B. fuer einen Timeout-Test).
* `setTransferNoop()` laesst genau den naechsten Transfer-Aufruf mit 201 und UNVERAENDERTEM
* `full_name` antworten (Gitea meldet Erfolg, hat aber nichts verschoben).
*/
export async function startFakeGitea(options = {}) {
const calls = []
let isDown = false
let transferDelayMs = 0
let responseDelayMs = 0
let transferNoopNext = false
const pendingFailures = []
let nextRepoId = 1
@@ -235,9 +242,17 @@ export async function startFakeGitea(options = {}) {
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
// Simuliert eine Gitea-Antwort, die Erfolg meldet, aber tatsaechlich nichts verschoben hat
// (Bug-Szenario fuer Controller-Ruling R10 Ein-Zeiler-Absicherung): 201 mit UNVERAENDERTEM
// full_name, kein Owner-/Key-Wechsel, keine Weiterleitung.
if (transferNoopNext) {
transferNoopNext = false
return send(res, 201, serializeRepo(record))
}
const key = found.key
const newOwner = body?.new_owner
if (!newOwner) return send(res, 422, { message: 'new_owner erforderlich' })
@@ -435,6 +450,8 @@ export async function startFakeGitea(options = {}) {
body: parsedBody,
})
if (responseDelayMs > 0) await sleep(responseDelayMs)
const failIdx = pendingFailures.findIndex(
(f) => f.method === req.method && url.pathname.includes(f.pathIncludes)
)
@@ -510,6 +527,16 @@ export async function startFakeGitea(options = {}) {
transferDelayMs = Number(ms) || 0
}
/** Verzoegert JEDE Antwort um `ms` (z.B. um einen `GITEA_TIMEOUT_MS`-Abbruch zu provozieren). */
function setResponseDelay(ms) {
responseDelayMs = Number(ms) || 0
}
/** Laesst den naechsten Transfer-Aufruf 201 mit UNVERAENDERTEM full_name melden (kein Umzug). */
function setTransferNoop() {
transferNoopNext = true
}
function failNext({ method, pathIncludes, status }) {
pendingFailures.push({ method, pathIncludes, status })
}
@@ -519,5 +546,17 @@ export async function startFakeGitea(options = {}) {
return new Promise((resolve) => server.close(() => resolve()))
}
return { baseUrl, calls, repo, addRepo, hooksOf, setDown, setTransferDelay, failNext, close }
return {
baseUrl,
calls,
repo,
addRepo,
hooksOf,
setDown,
setTransferDelay,
setResponseDelay,
setTransferNoop,
failNext,
close,
}
}
+36
View File
@@ -44,6 +44,7 @@ before(async () => {
{ full_name: 'JUSN/doc-fail-test' },
{ full_name: 'JUSN/verboten' },
{ full_name: 'JUSN/Case-Test' },
{ full_name: 'knso/rausgezogen' },
],
// 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`.
@@ -53,10 +54,14 @@ before(async () => {
'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/rausgezogen': [{ id: 510, url: PORTAL_HOOK_URL }],
},
// knso/von-hand wurde von Hand bereits nach WEBklar/von-hand verschoben (kein Transfer-Aufruf noetig).
// WEBklar/rausgezogen wurde von Hand AUS der Organisation heraus nach knso/rausgezogen verschoben
// (final-fix Punkt 2: Schritt-1-Aufraeumen darf dieser Weiterleitung nicht folgen).
redirects: {
'knso/von-hand': 'WEBklar/von-hand',
'WEBklar/rausgezogen': 'knso/rausgezogen',
},
})
@@ -72,6 +77,7 @@ before(async () => {
'JUSN/doc-fail-test',
'JUSN/verboten',
'jusn/case-test', // Appwrite kennt eine andere Schreibweise als Gitea (dort: JUSN/Case-Test)
'WEBklar/rausgezogen', // Appwrite kennt noch den WEBklar-Namen, Gitea hat es herausverschoben
]
const websiteProjects = repoNames.map((repoFullName) => {
const $id = randomUUID()
@@ -200,3 +206,33 @@ test('Gitea nicht erreichbar', async () => {
assert.equal(r.moved, null)
assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar \(.+\)\. Bitte später ‚Pushes nachtragen‘ klicken\.$/)
})
test('Schritt 1 folgt keiner Weiterleitung: von Hand aus WEBklar herausverschobenes Repo wird nicht angefasst', async () => {
// final-fix Punkt 2: WEBklar/rausgezogen (isOrgRepo(from) === true) leitet inzwischen auf
// knso/rausgezogen um - das Aufraeumen darf dem NICHT folgen (sonst wuerde der Portal-Webhook
// eines fremden Repos geloescht). Stattdessen wird der kanonische Name aufgeloest und, weil er
// ausserhalb von WEBklar liegt, nur das Projektdokument nachgezogen.
const r = await moveRepoToOrg(doc('WEBklar/rausgezogen'))
assert.deepEqual(r, { moved: null, warnings: [] })
assert.equal(
gitea.calls.some((c) => c.method === 'DELETE' && /rausgezogen/i.test(c.path)),
false
)
assert.deepEqual(gitea.hooksOf('knso/rausgezogen'), [
{ id: 510, type: 'gitea', active: true, events: ['push'], config: { content_type: 'json', url: PORTAL_HOOK_URL } },
])
const d = aw.get('websiteProjects', doc('WEBklar/rausgezogen').$id)
assert.equal(d.repoFullName, 'knso/rausgezogen')
})
test('R10-Ein-Zeiler: Transfer meldet Erfolg, aber unveraenderten full_name -> kein Aufraeumen', async () => {
// final-fix Punkt 3: der Transfer selbst antwortet 201, aber full_name ist danach nicht in
// WEBklar (simuliert einen Gitea-Bug/Teil-Fehlschlag). Erst NACH dieser Pruefung darf
// aufgeraeumt werden - hier eben nicht.
gitea.setTransferNoop()
const r = await moveRepoToOrg(doc('ANDJ/unberuehrt'))
assert.equal(r.moved, null)
assert.deepEqual(r.warnings, ['Umzug nach WEBklar nicht möglich (Übertragung nicht abgeschlossen).'])
assert.equal(
gitea.calls.some((c) => c.method === 'DELETE' && /unberuehrt/i.test(c.path)),
false
)
})