Add secure Node image webhook service
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user