Files
image/node-hook/test/git-service.test.mjs
T

127 lines
5.8 KiB
JavaScript

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/,
);
});