import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim(); const ciScript = resolve('scripts/release/ci.mjs'); function fixture(t) { const cwd = mkdtempSync(join(tmpdir(), 'fedeo-ci-permissions-')); t.after(() => rmSync(cwd, { recursive: true, force: true })); const git = args => execFileSync(realGit, args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); git(['init', '-b', 'main']); git(['config', 'user.name', 'Release Test']); git(['config', 'user.email', 'release@example.test']); git(['commit', '--allow-empty', '-m', 'chore: baseline']); const baseline = git(['rev-parse', 'HEAD']); const remote = join(cwd, 'remote.git'); git(['init', '--bare', remote]); git(['remote', 'add', 'origin', remote]); git(['push', 'origin', 'main']); git(['checkout', '-b', 'dev']); writeFileSync(join(cwd, 'release.config.json'), JSON.stringify({ initialVersion: '3.0.0', bootstrapCommit: baseline, branch: 'main', repositoryUrl: 'https://git.example.test/owner/repo', registry: 'registry.example.test/fedeo', images: [{ name: 'backend', context: 'backend', dockerfile: 'backend/Dockerfile' }], })); git(['add', 'release.config.json']); git(['commit', '-m', 'feat: add release configuration']); git(['push', 'origin', 'dev']); const sha = git(['rev-parse', 'HEAD']); const bin = join(cwd, 'bin'); mkdirSync(bin); const gitTrace = join(cwd, 'git-calls.jsonl'); const dockerTrace = join(cwd, 'docker-calls.jsonl'); const registryFile = join(cwd, 'registry.json'); writeFileSync(gitTrace, ''); writeFileSync(dockerTrace, ''); writeFileSync(registryFile, '{}'); // All Git operations are real and local, except that the simulated CI user cannot push. writeFileSync(join(bin, 'git'), `#!/usr/bin/env node const { appendFileSync } = require('node:fs'); const { spawnSync } = require('node:child_process'); const args = process.argv.slice(2); appendFileSync(process.env.TEST_GIT_TRACE, JSON.stringify(args) + '\\n'); if (args[0] === 'push') { process.stderr.write('remote: Forbidden\\nfatal: requested URL returned error: 403\\n'); process.exit(128); } if (process.env.TEST_STALE_DEV === 'true' && args[0] === 'ls-remote' && args[2] === 'refs/heads/dev') { process.stdout.write('b'.repeat(40) + '\\trefs/heads/dev\\n'); process.exit(0); } const result = spawnSync(process.env.TEST_REAL_GIT, args, { stdio: 'inherit' }); process.exit(result.status ?? 1); `, { mode: 0o755 }); // Simulate only Docker's registry boundary; no external service or credential is used. writeFileSync(join(bin, 'docker'), `#!/usr/bin/env node const { appendFileSync, readFileSync, writeFileSync } = require('node:fs'); const { createHash } = require('node:crypto'); const args = process.argv.slice(2); appendFileSync(process.env.TEST_DOCKER_TRACE, JSON.stringify(args) + '\\n'); const registry = JSON.parse(readFileSync(process.env.TEST_REGISTRY_FILE, 'utf8')); if (args[1] === 'build') { const tag = args[args.indexOf('--tag') + 1]; const digest = 'sha256:' + createHash('sha256').update(tag).digest('hex'); registry[tag] = digest; registry[tag.slice(0, tag.lastIndexOf(':')) + '@' + digest] = digest; writeFileSync(args[args.indexOf('--metadata-file') + 1], JSON.stringify({ 'containerimage.digest': digest })); } else if (args[1] === 'imagetools' && args[2] === 'create') { const digest = registry[args.at(-1)]; if (!digest) process.exit(1); registry[args[args.indexOf('--tag') + 1]] = digest; } else if (args[1] === 'imagetools' && args[2] === 'inspect') { const digest = registry[args[3]]; if (!digest) { process.stderr.write('manifest unknown\\n'); process.exit(1); } process.stdout.write(JSON.stringify({ digest })); } else { process.exit(1); } writeFileSync(process.env.TEST_REGISTRY_FILE, JSON.stringify(registry)); `, { mode: 0o755 }); const run = (extra = {}) => spawnSync(process.execPath, [ciScript], { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, CI: 'true', FEDEO_REF: 'refs/heads/dev', FEDEO_SHA: sha, FEDEO_EVENT_NAME: 'push', GITEA_TOKEN: '', TEST_REAL_GIT: realGit, TEST_GIT_TRACE: gitTrace, TEST_DOCKER_TRACE: dockerTrace, TEST_REGISTRY_FILE: registryFile, ...extra, }, }); const calls = path => readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); return { sha, run, gitCalls: () => calls(gitTrace), dockerCalls: () => calls(dockerTrace), registry: () => JSON.parse(readFileSync(registryFile, 'utf8')) }; } test('dev builds and retries succeed without a release token or Git push permissions', t => { const f = fixture(t); for (let attempt = 0; attempt < 2; attempt++) { const result = f.run(); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); } assert.ok(f.gitCalls().every(args => !['push', 'commit-tree'].includes(args[0]))); const snapshots = Object.keys(f.registry()).filter(tag => tag.includes(`:dev-sha-${f.sha}-`)); assert.equal(snapshots.length, 2, 'Reruns must have separate snapshots, even for the same commit'); assert.equal(f.registry()['registry.example.test/fedeo/backend:dev'], f.registry()[snapshots[1]]); }); test('an outdated dev run keeps its snapshot without advancing the dev channel', t => { const f = fixture(t); const result = f.run({ TEST_STALE_DEV: 'true' }); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.equal(f.registry()['registry.example.test/fedeo/backend:dev'], undefined); assert.match(result.stdout, /neuerer dev-Commit/); assert.ok(f.dockerCalls().some(args => args[1] === 'build')); }); test('main still requires a release credential before building or publishing', t => { const f = fixture(t); const result = f.run({ FEDEO_REF: 'refs/heads/main' }); assert.equal(result.status, 1); assert.match(result.stderr, /RELEASE_TOKEN.*write:repository/); assert.equal(f.dockerCalls().length, 0); }); test('a forbidden main lock fails with an actionable message and never builds unlocked', t => { const f = fixture(t); const result = f.run({ FEDEO_REF: 'refs/heads/main', GITEA_TOKEN: 'synthetic-test-token' }); assert.equal(result.status, 1); assert.match(result.stderr, /main-Release-Sperre benötigt Git-Schreibrechte/); assert.doesNotMatch(result.stderr, /synthetic-test-token/); assert.ok(f.gitCalls().some(args => args[0] === 'push')); assert.equal(f.dockerCalls().length, 0); });