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>
203 lines
8.8 KiB
JavaScript
203 lines
8.8 KiB
JavaScript
import { test, before, after } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import fs from 'node:fs/promises'
|
||
import os from 'node:os'
|
||
import path from 'node:path'
|
||
import { randomUUID } from 'node:crypto'
|
||
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||
|
||
let gitea
|
||
let aw
|
||
let tmp
|
||
let originalCwd
|
||
let previewWorkDir
|
||
let moveRepoToOrg
|
||
let isOrgRepo
|
||
let isInternalRepo
|
||
let repoLockKey
|
||
let currentRepoFullName
|
||
|
||
// Schluessel: urspruenglich gesetzter repoFullName -> Appwrite-$id. Wird beim Seeden befuellt
|
||
// (siehe Controller-Ruling R2) - eine Live-Suche ueber repoFullName wuerde nach einem Umzug
|
||
// nichts mehr finden, weil sich das Feld dann geaendert hat.
|
||
const idByRepo = new Map()
|
||
|
||
/** Liefert das gespeicherte Dokument des Repos, ueber den zur Seed-Zeit gebauten Id-Index. */
|
||
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: [
|
||
{ full_name: 'JUSN/D-ner-Nation' },
|
||
{ full_name: 'knso/emmas-schatzkiste' },
|
||
{ full_name: 'ANDJ/Steuerungstechnik' },
|
||
{ full_name: 'knso/hytail-ark' },
|
||
{ full_name: 'WEBklar/schon-da' },
|
||
{ 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: 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: {
|
||
'knso/von-hand': 'WEBklar/von-hand',
|
||
},
|
||
})
|
||
|
||
const repoNames = [
|
||
'JUSN/D-ner-Nation',
|
||
'knso/emmas-schatzkiste',
|
||
'ANDJ/Steuerungstechnik',
|
||
'knso/hytail-ark',
|
||
'WEBklar/schon-da',
|
||
'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()
|
||
idByRepo.set(repoFullName, $id)
|
||
return { $id, repoFullName, projectName: repoFullName.split('/').pop() }
|
||
})
|
||
|
||
aw = await startFakeAppwrite({ seed: { websiteProjects } })
|
||
|
||
originalCwd = process.cwd()
|
||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-move-'))
|
||
process.chdir(tmp)
|
||
|
||
Object.assign(process.env, {
|
||
GITEA_BASE_URL: gitea.baseUrl,
|
||
GITEA_API_TOKEN: 'test-token',
|
||
APPWRITE_ENDPOINT: aw.endpoint,
|
||
APPWRITE_API_KEY: 'test-key',
|
||
PREVIEW_DEPLOY_ROOT: '',
|
||
TRAEFIK_DYNAMIC_DIR: '',
|
||
})
|
||
// config.js liest die Umgebung beim Import
|
||
;({ previewWorkDir } = await import('../server/services/previewDeploy.js'))
|
||
;({ moveRepoToOrg, isOrgRepo, isInternalRepo, repoLockKey, currentRepoFullName } = await import(
|
||
'../server/services/repoMove.js'
|
||
))
|
||
})
|
||
|
||
after(async () => {
|
||
process.chdir(originalCwd)
|
||
await gitea.close()
|
||
await aw.close()
|
||
await fs.rm(tmp, { recursive: true, force: true })
|
||
})
|
||
|
||
test('verschiebt Repo aus eigenem Konto', async () => {
|
||
await fs.mkdir(previewWorkDir('JUSN/D-ner-Nation'), { recursive: true })
|
||
const r = await moveRepoToOrg(doc('JUSN/D-ner-Nation'))
|
||
assert.deepEqual(r, { moved: { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }, warnings: [] })
|
||
assert.deepEqual(gitea.calls.find((c) => c.path.endsWith('/transfer')).body, { new_owner: 'WEBklar' })
|
||
const d = aw.get('websiteProjects', doc('JUSN/D-ner-Nation').$id)
|
||
assert.equal(d.repoFullName, 'WEBklar/D-ner-Nation')
|
||
assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/D-ner-Nation`)
|
||
assert.equal(d.giteaRepoName, 'D-ner-Nation')
|
||
await assert.rejects(fs.access(previewWorkDir('JUSN/D-ner-Nation')))
|
||
})
|
||
test('löscht den alten Portal-Webhook', async () => {
|
||
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 () => {
|
||
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']) {
|
||
assert.deepEqual(await moveRepoToOrg(doc(name)), { moved: null, warnings: ['Internes Repo, bleibt wo es ist.'] })
|
||
}
|
||
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && /steuerung|hytail/i.test(c.path)).length, 0)
|
||
})
|
||
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)
|
||
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)
|
||
assert.equal(await isInternalRepo('andj/steuerungstechnik'), true)
|
||
})
|
||
test('repoLockKey', () => {
|
||
assert.equal(repoLockKey('JUSN/D-ner-Nation'), 'repo:jusn/d-ner-nation')
|
||
})
|
||
test('currentRepoFullName', async () => {
|
||
assert.equal(await currentRepoFullName('WEBklar/D-ner-Nation'), 'WEBklar/D-ner-Nation') // Eintrag vorhanden
|
||
assert.equal(await currentRepoFullName('JUSN/D-ner-Nation'), 'WEBklar/D-ner-Nation') // kein Eintrag, Weiterleitung
|
||
})
|
||
test('Gitea nicht erreichbar', async () => {
|
||
gitea.setDown(true)
|
||
const r = await moveRepoToOrg(doc('ANDJ/unberuehrt'))
|
||
gitea.setDown(false)
|
||
assert.equal(r.moved, null)
|
||
assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar \(.+\)\. Bitte später ‚Pushes nachtragen‘ klicken\.$/)
|
||
})
|