Add secure Node image webhook service
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { adminSignature, hmacHex, verifyAdminSignature, verifyHexHmac } from '../src/auth.mjs';
|
||||
|
||||
test('Gitea HMAC verifies the exact raw body', () => {
|
||||
const body = Buffer.from('{"ref":"refs/heads/master"}');
|
||||
const signature = hmacHex('a'.repeat(32), body);
|
||||
assert.equal(verifyHexHmac('a'.repeat(32), body, signature), true);
|
||||
assert.equal(verifyHexHmac('a'.repeat(32), Buffer.from(`${body} `), signature), false);
|
||||
assert.equal(verifyHexHmac('a'.repeat(32), body, 'not-a-signature'), false);
|
||||
});
|
||||
|
||||
test('admin signatures bind timestamp, request ID, and body with a five-minute window', () => {
|
||||
const secret = 'b'.repeat(32);
|
||||
const now = 1_786_013_000_000;
|
||||
const timestamp = String(now);
|
||||
const requestId = 'request-1234';
|
||||
const body = Buffer.from('{"branch":"master"}');
|
||||
const supplied = adminSignature(secret, timestamp, requestId, body);
|
||||
assert.equal(verifyAdminSignature({ secret, timestamp, requestId, body, supplied, now }), true);
|
||||
assert.equal(verifyAdminSignature({ secret, timestamp: String(now - 300_001), requestId, body, supplied, now }), false);
|
||||
assert.equal(verifyAdminSignature({ secret, timestamp, requestId: 'changed-id', body, supplied, now }), false);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildLegacyInventory } from '../src/inventory.mjs';
|
||||
|
||||
test('legacy inventory preserves dirname to stem to basename shape', () => {
|
||||
assert.deepEqual(buildLegacyInventory([
|
||||
'game/back.jpg',
|
||||
'icons/1047.jpg',
|
||||
'icons/장수/유표.jpg',
|
||||
'icons/장수/유표1.png',
|
||||
]), {
|
||||
'.': { '1047': '1047.jpg' },
|
||||
'장수': { '유표': '유표.jpg', '유표1': '유표1.png' },
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy duplicate stems retain the last Git path for PHP compatibility', () => {
|
||||
assert.deepEqual(buildLegacyInventory(['icons/a/same.jpg', 'icons/a/same.png']), {
|
||||
a: { same: 'same.png' },
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user