import test from 'node:test'; import assert from 'node:assert/strict'; import { publishRelease, assertManifest, releaseFiles } from '../publish.mjs'; import { Gitea } from '../gitea.mjs'; function fixture() { const plan = { version: '1.2.0', tag: 'v1.2.0', sha: 'a'.repeat(40), notes: 'Release notes' }; const config = { registry: 'registry.test/fedeo', images: [{ name: 'backend' }, { name: 'frontend' }] }; const images = config.images.map((item, i) => ({ ...item, repository: `${config.registry}/${item.name}`, digest: `sha256:${String(i).repeat(64)}` })); const manifest = { version: plan.version, tag: plan.tag, commit: plan.sha, images }; const events = []; let release = null; let tag = null; const assets = new Map(); const api = { async releases() { return release ? [release] : []; }, async release() { return release; }, async tag() { return tag; }, async asset(_release, name) { return assets.get(name); }, async upload(_release, name, bytes) { events.push(`upload:${name}`); if (assets.has(name)) assert.deepEqual(assets.get(name), bytes); assets.set(name, bytes); }, async request(_path, { method, body }) { if (method === 'POST') { events.push('draft'); // Gitea drafts deliberately do NOT create a tag. release = { ...body, id: 1 }; } else { events.push('publish'); release = { ...release, ...body }; tag = { commit: { sha: plan.sha } }; } return release; }, }; const dependencies = { plan, config, api, build: async () => { events.push('build'); return images; }, promote: async (image, _tags, immutable) => { events.push(`${immutable ? 'version' : 'channel'}:${image.name}`); }, manifestFactory: () => manifest, files: (saved, notes) => new Map([['release-manifest.json', Buffer.from(JSON.stringify(saved))], ['CHANGELOG.md', Buffer.from(notes)]]), }; return { plan, config, manifest, api, events, assets, dependencies, getRelease: () => release, setTag: value => { tag = value; } }; } test('publish only after builds, assets and immutable images; channels move last', async () => { const f = fixture(); await publishRelease(f.dependencies); assert.deepEqual(f.events, ['build', 'draft', 'upload:release-manifest.json', 'upload:CHANGELOG.md', 'version:backend', 'version:frontend', 'publish', 'channel:backend', 'channel:frontend']); }); test('failed build never creates a draft or changes release channels', async () => { const f = fixture(); f.dependencies.build = async () => { throw new Error('build failed'); }; await assert.rejects(publishRelease(f.dependencies), /build failed/); assert.deepEqual(f.events, []); }); test('asynchronous manifest generation finishes before creating the release draft', async () => { const f = fixture(); f.dependencies.manifestFactory = async () => { await new Promise(resolve => setImmediate(resolve)); f.events.push('manifest'); return f.manifest; }; await publishRelease(f.dependencies); assert.deepEqual(f.events.slice(0, 3), ['build', 'manifest', 'draft']); assert.equal(f.getRelease().draft, false); }); test('failed migration hashing never creates a draft or promotes images', async () => { const f = fixture(); f.dependencies.manifestFactory = async () => { throw new Error('migration hashing failed'); }; await assert.rejects(publishRelease(f.dependencies), /migration hashing failed/); assert.deepEqual(f.events, ['build']); assert.equal(f.getRelease(), null); }); test('retry a draft uses saved digests and original notes without rebuilding', async () => { const f = fixture(); f.dependencies.promote = async () => { throw new Error('registry interrupted'); }; await assert.rejects(publishRelease(f.dependencies), /registry interrupted/); assert.equal(f.getRelease().draft, true); assert.equal(await f.api.tag(), null); f.plan.notes = 'Notes generated on another day'; f.dependencies.build = async () => { throw new Error('must not rebuild'); }; f.dependencies.promote = async () => {}; await publishRelease(f.dependencies); assert.equal(f.getRelease().draft, false); assert.equal(f.assets.get('CHANGELOG.md').toString(), 'Release notes'); }); test('retry after publication only repairs mutable channels', async () => { const f = fixture(); const promote = f.dependencies.promote; f.dependencies.promote = async (image, tags, immutable) => { if (!immutable) throw new Error('channel update interrupted'); await promote(image, tags, immutable); }; await assert.rejects(publishRelease(f.dependencies), /channel update interrupted/); f.events.length = 0; f.dependencies.promote = promote; f.dependencies.build = async () => { throw new Error('must not rebuild'); }; await publishRelease(f.dependencies); assert.deepEqual(f.events, ['channel:backend', 'channel:frontend']); }); test('partial asset upload remains a draft and retries safely', async () => { const f = fixture(); const upload = f.api.upload; f.api.upload = async (...args) => { if (args[1] === 'CHANGELOG.md') throw new Error('upload interrupted'); return upload(...args); }; await assert.rejects(publishRelease(f.dependencies), /upload interrupted/); assert.equal(f.getRelease().draft, true); assert.ok(!f.events.includes('publish')); f.api.upload = upload; f.dependencies.build = async () => { throw new Error('must not rebuild'); }; await publishRelease(f.dependencies); }); test('another pending release must be completed first', async () => { const f = fixture(); f.api.releases = async () => [{ draft: true, tag_name: 'v1.1.0' }]; await assert.rejects(publishRelease(f.dependencies), /unvollständigen Release/); assert.deepEqual(f.events, []); }); test('a historical rerun cannot roll latest back', async () => { const f = fixture(); f.api.releases = async () => [{ draft: false, tag_name: 'v2.0.0' }]; await assert.rejects(publishRelease(f.dependencies), /Downgrade/); }); test('tag collisions and manifests with wrong digests or commits are rejected', async () => { const f = fixture(); f.setTag({ commit: { sha: 'b'.repeat(40) } }); await assert.rejects(publishRelease(f.dependencies), /anderen Commit/); assert.throws(() => assertManifest({ ...f.manifest, commit: 'bad' }, f.plan, f.config)); const malformed = structuredClone(f.manifest); malformed.images[0].digest = 'latest'; assert.throws(() => assertManifest(malformed, f.plan, f.config)); }); test('release assets pin compose, environment and installer to one version', () => { const f = fixture(); const files = releaseFiles(f.manifest, f.plan.notes); assert.match(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-1\.2\.0/); assert.doesNotMatch(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-dev/); assert.match(files.get('.env.example').toString(), /^FEDEO_VERSION=1\.2\.0$/m); assert.match(files.get('selfhost-install.sh').toString(), /FEDEO_REF:-v1\.2\.0/); for (const name of ['selfhost-update-service.py', 'selfhost-update-service-install.sh', 'fedeo-updater.service']) { assert.ok(files.get(name)?.length > 0, `missing service asset: ${name}`); assert.ok(files.get('SHA256SUMS').toString().includes(` ${name}\n`)); } assert.equal(files.get('SHA256SUMS').toString().trim().split('\n').length, files.size - 1); }); test('Gitea API differentiates missing resources from permission and server errors', async () => { const api = new Gitea('https://git.example.test/owner/repo', 'secret', async () => new Response('', { status: 404 })); assert.equal(await api.release('v1.0.0'), null); api.fetch = async () => new Response('', { status: 403 }); await assert.rejects(api.release('v1.0.0'), /HTTP 403/); api.fetch = async () => new Response('', { status: 500 }); await assert.rejects(api.release('v1.0.0'), /HTTP 500/); }); test('Gitea download follows object-storage redirects without leaking the token', async () => { const requests = []; const api = new Gitea('https://git.example.test/owner/repo', 'secret', async (url, options) => { requests.push({ url, options }); if (url.endsWith('/assets')) return Response.json([{ id: 2, name: 'manifest', browser_download_url: 'https://git.example.test/attachments/uuid' }]); if (url.includes('/attachments/')) return new Response(null, { status: 302, headers: { location: 'https://objects.example.test/signed-file' } }); return new Response('file bytes'); }); assert.equal((await api.asset({ id: 1 }, 'manifest')).toString(), 'file bytes'); assert.equal(requests[1].options.headers.Authorization, 'token secret'); assert.deepEqual(requests[2].options.headers, {}); }); test('Gitea upload retries compare existing bytes and refuse conflicting assets', async () => { const api = new Gitea('https://git.example.test/owner/repo', 'secret'); api.request = async () => [{ id: 1, name: 'manifest' }]; api.asset = async () => Buffer.from('original'); await api.upload({ id: 1 }, 'manifest', Buffer.from('original')); await assert.rejects(api.upload({ id: 1 }, 'manifest', Buffer.from('changed')), /anderem Inhalt/); });