import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import test from 'node:test'; import { DeploymentError, GitService } from '../src/git-service.mjs'; const exec = promisify(execFile); async function git(cwd, ...args) { const { stdout } = await exec('git', ['-C', cwd, ...args]); return stdout.trim(); } async function fixture() { const root = await mkdtemp(join(tmpdir(), 'image-hook-test-')); const remote = join(root, 'remote.git'); const seed = join(root, 'seed'); const deployed = join(root, 'deployed'); await exec('git', ['init', '--bare', remote]); await exec('git', ['init', '-b', 'master', seed]); await git(seed, 'config', 'user.email', 'test@example.invalid'); await git(seed, 'config', 'user.name', 'Image Test'); await mkdir(join(seed, 'icons'), { recursive: true }); await mkdir(join(seed, 'hook'), { recursive: true }); await writeFile(join(seed, 'icons', 'first.jpg'), 'first'); await writeFile(join(seed, 'hook', '.keep'), ''); await git(seed, 'add', '.'); await git(seed, 'commit', '-m', 'initial'); await git(seed, 'remote', 'add', 'origin', remote); await git(seed, 'push', '-u', 'origin', 'master'); await exec('git', ['clone', remote, deployed]); const config = { repositoryPath: deployed, remoteUrl: remote, repositoryFullName: 'devsam/image', defaultBranch: 'master', allowedBranches: ['master', 'preview'], statePath: join(root, 'state', 'state.json'), publicBases: ['https://sam.hided.net/image', 'https://sam-image.hided.net'], }; const service = new GitService(config, { now: () => new Date('2026-08-06T00:00:00Z') }); await service.initialize(); return { root, remote, seed, deployed, service }; } test('push deployment fast-forwards, writes inventory, and deduplicates delivery IDs', async (t) => { const f = await fixture(); t.after(() => rm(f.root, { recursive: true, force: true })); await writeFile(join(f.seed, 'icons', '둘째.png'), 'second'); await git(f.seed, 'add', '.'); await git(f.seed, 'commit', '-m', 'second'); await git(f.seed, 'push', 'origin', 'master'); const target = await git(f.seed, 'rev-parse', 'HEAD'); const result = await f.service.deployWebhook({ deliveryId: 'delivery-0001', branch: 'master', after: target }); assert.equal(result.changed, true); assert.equal(await git(f.deployed, 'rev-parse', 'HEAD'), target); const inventory = JSON.parse(await readFile(join(f.deployed, 'hook', 'inventory.v2.json'), 'utf8')); assert.equal(inventory.directories['.']['둘째'], '둘째.png'); assert.equal((await f.service.deployWebhook({ deliveryId: 'delivery-0001', branch: 'master', after: target })).duplicate, true); }); test('inactive branches are ignored and tracked changes block deployment', async (t) => { const f = await fixture(); t.after(() => rm(f.root, { recursive: true, force: true })); assert.equal((await f.service.deployWebhook({ deliveryId: 'delivery-0002', branch: 'preview', after: 'a'.repeat(40) })).ignored, true); await writeFile(join(f.deployed, 'icons', 'first.jpg'), 'dirty'); await assert.rejects( f.service.deployWebhook({ deliveryId: 'delivery-0003', branch: 'master', after: await git(f.seed, 'rev-parse', 'HEAD') }), (error) => error instanceof DeploymentError && /Tracked worktree/.test(error.message), ); }); test('admin deploy explicitly switches to an allowlisted unrelated branch', async (t) => { const f = await fixture(); t.after(() => rm(f.root, { recursive: true, force: true })); await git(f.seed, 'checkout', '--orphan', 'preview'); await git(f.seed, 'rm', '-rf', '.'); await mkdir(join(f.seed, 'icons'), { recursive: true }); await mkdir(join(f.seed, 'hook'), { recursive: true }); await writeFile(join(f.seed, 'icons', 'preview.jpg'), 'preview'); await writeFile(join(f.seed, 'hook', '.keep'), ''); await git(f.seed, 'add', '.'); await git(f.seed, 'commit', '-m', 'preview'); await git(f.seed, 'push', 'origin', 'preview'); const target = await git(f.seed, 'rev-parse', 'HEAD'); const result = await f.service.deployAdmin({ requestId: 'admin-request-1', branch: 'preview', expectedCommit: target }); assert.equal(result.changed, true); assert.equal(f.service.publicStatus().activeBranch, 'preview'); await assert.rejects( f.service.deployAdmin({ requestId: 'admin-request-2', branch: 'forbidden' }), /Branch is not allowed/, ); }); test('same-branch force pushes and payload SHA mismatches are rejected', async (t) => { const f = await fixture(); t.after(() => rm(f.root, { recursive: true, force: true })); const initial = await git(f.seed, 'rev-parse', 'HEAD'); await writeFile(join(f.seed, 'icons', 'second.jpg'), 'second'); await git(f.seed, 'add', '.'); await git(f.seed, 'commit', '-m', 'second'); await git(f.seed, 'push', 'origin', 'master'); const second = await git(f.seed, 'rev-parse', 'HEAD'); await f.service.deployWebhook({ deliveryId: 'delivery-0004', branch: 'master', after: second }); await git(f.seed, 'reset', '--hard', initial); await writeFile(join(f.seed, 'icons', 'forced.jpg'), 'forced'); await git(f.seed, 'add', '.'); await git(f.seed, 'commit', '-m', 'forced replacement'); await git(f.seed, 'push', '--force', 'origin', 'master'); const forced = await git(f.seed, 'rev-parse', 'HEAD'); await assert.rejects( f.service.deployWebhook({ deliveryId: 'delivery-0005', branch: 'master', after: forced }), /Non-fast-forward deployment rejected/, ); await assert.rejects( f.service.deployWebhook({ deliveryId: 'delivery-0006', branch: 'master', after: 'f'.repeat(40) }), /Payload commit does not match remote branch tip/, ); });