Files

185 lines
28 KiB
JavaScript
Raw Permalink Normal View History

/**
* test/release-provenance.test.mjs — Ariweb Suite 0.2.0 검토 수정(독립 검토 차단 항목 11): 릴리스 build·register --approve·HAL 승인은
* IMPORTED_SOURCES 의 도입 소스 출처(provenance)가 깨끗하지 않으면(작업 트리 변경을 함께 가져온 workingTreeDirty=true, 고정 커밋 ≠ upstream.headCommit,
* 불완전한 기록) **fail-closed** 로 멈춘다. --allow-dirty 로 나란히 개발하는 동안 만든 Suite 체크아웃이 그대로 릴리스로 승인되는 길을 막는다.
* 1) lib/provenance.mjs checkProvenance(manifest) 순수 규칙
* 2) scripts/release.mjs build 가 더러운 manifest 로는 artifact 를 만들지 않고 종료 코드 1, 깨끗한 manifest 로는 manifest.json 에 checks.provenanceClean=true
* 3) scripts/release.mjs register --approve 가 provenanceClean≠true 인 dist manifest 를 승인하지 않는다(DB 를 열기 전에 멈춤)
* 4) hal/core.approveRelease 가 checks.provenanceClean=false 릴리스를 승인하지 않는다
*/
import { test, eq, ok, tmpDir, done, rejects } from '../lib/test.mjs';
import { spawnSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const HEX = 'a'.repeat(40);
const clean = () => ({ generatedAt: 'x', sources: [
{ name: 'webmaker', repo: 'r', commit: HEX, dest: 'modules/webmaker', license: 'l', upstream: { version: '1.8.0', branch: 'main', headCommit: HEX, workingTreeDirty: false, note: '' }, files: [{ from: 'a.js', to: 'modules/webmaker/a.js', status: 'unchanged', origHash: '0123456789abcdef' }] },
{ name: 'arimail', repo: 'r', commit: 'b'.repeat(40), dest: 'modules/arimail', license: 'l', files: [{ from: 'b.js', to: 'modules/arimail/b.js', status: 'modified', origHash: '0123456789abcdef' }] }, // 0.1.x 기록(upstream 칸 없음 — 깨끗한 고정 커밋만 받던 스크립트가 만든 것)
] });
await test('checkProvenance: 깨끗한 기록은 ok, workingTreeDirty·headCommit 불일치·불완전한 upstream·잘못된 commit·빈 파일 목록은 문제로 나열(fail-closed)', async () => {
const { checkProvenance } = await import('../lib/provenance.mjs');
const c = checkProvenance(clean()); eq(c.ok, true, JSON.stringify(c.problems)); eq(c.problems, []); eq(c.sources.map((s) => s.name), ['webmaker', 'arimail']); eq(c.sources[1].legacy, true, '0.1.x 기록은 legacy 표시');
const dirty = clean(); dirty.sources[0].upstream.workingTreeDirty = true; const r1 = checkProvenance(dirty); eq(r1.ok, false); ok(r1.problems.some((p) => /webmaker/.test(p) && /workingTreeDirty|작업 트리/.test(p)), JSON.stringify(r1.problems));
const mism = clean(); mism.sources[0].upstream.headCommit = 'c'.repeat(40); const r2 = checkProvenance(mism); eq(r2.ok, false); ok(r2.problems.some((p) => /headCommit/.test(p)));
const inc = clean(); delete inc.sources[0].upstream.version; const r3 = checkProvenance(inc); eq(r3.ok, false); ok(r3.problems.some((p) => /version/.test(p)));
const badc = clean(); badc.sources[0].commit = 'abc'; const r4 = checkProvenance(badc); eq(r4.ok, false); ok(r4.problems.some((p) => /commit/.test(p)));
const nof = clean(); nof.sources[0].files = []; const r5 = checkProvenance(nof); eq(r5.ok, false); ok(r5.problems.some((p) => /파일/.test(p)));
eq(checkProvenance(null).ok, false); eq(checkProvenance({ sources: [] }).ok, false, '소스가 하나도 없으면 불완전');
const dirtyNote = clean(); dirtyNote.sources[0].upstream = { ...dirtyNote.sources[0].upstream, workingTreeDirty: 'true' }; eq(checkProvenance(dirtyNote).ok, false, '불리언 아닌 값도 깨끗함으로 보지 않는다');
});
await test('release.mjs build: 더러운 출처 manifest 로는 artifact 를 만들지 않고 종료 1(이유 출력), 깨끗한 manifest 로는 manifest.json 에 checks.provenanceClean=true 와 imported[].upstream 요약', () => {
const dir = tmpDir('aw-prov-'); const dirtyM = clean(); dirtyM.sources[0].upstream.workingTreeDirty = true; writeFileSync(join(dir, 'dirty.json'), JSON.stringify(dirtyM)); writeFileSync(join(dir, 'clean.json'), JSON.stringify(clean()));
const out1 = join(dir, 'dist1'); const r1 = spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'build', '--skip-tests', '--out', out1], { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ARIWEB_IMPORTED_SOURCES: join(dir, 'dirty.json') } });
eq(r1.status, 1, r1.stdout + r1.stderr); ok(/출처|provenance/i.test(r1.stderr + r1.stdout), '이유 출력'); ok(/webmaker/.test(r1.stderr + r1.stdout));
ok(!existsSync(out1) || readdirSync(out1).every((d) => !readdirSync(join(out1, d)).some((f) => f.endsWith('.tar.gz'))), 'artifact 없음');
const out2 = join(dir, 'dist2'); const r2 = spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'build', '--skip-tests', '--out', out2], { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ARIWEB_IMPORTED_SOURCES: join(dir, 'clean.json') } });
eq(r2.status, 0, r2.stdout + r2.stderr);
const ver = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; const mf = JSON.parse(readFileSync(join(out2, ver, 'manifest.json'), 'utf8'));
eq(mf.checks.provenanceClean, true); eq(mf.checks.provenanceProblems, []); eq(mf.imported[0].name, 'webmaker'); eq(mf.imported[0].upstream.workingTreeDirty, false); eq(mf.imported[0].upstream.headCommit, HEX); ok(mf.artifacts.webmaker && mf.artifacts.webmaker.sha256);
});
await test('release.mjs register --approve: dist manifest 의 checks.provenanceClean 이 true 가 아니면(false·없음) 승인하지 않고 종료 1', () => {
const dir = tmpDir('aw-prov-reg-'); const ver = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
for (const [label, checks] of [['false', { testsPassed: true, dirty: false, provenanceClean: false, provenanceProblems: ['webmaker: workingTreeDirty'] }], ['없음', { testsPassed: true, dirty: false }]]) {
const dist = join(dir, 'dist-' + label, ver); mkdirSync(dist, { recursive: true }); writeFileSync(join(dist, 'manifest.json'), JSON.stringify({ version: ver, mainCommit: HEX, branch: 'main', builtAt: 'x', artifacts: {}, checks, imported: [] }));
const r = spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'register', '--hal-data', join(dir, 'hal-' + label), '--dist', dist, '--approve'], { cwd: ROOT, encoding: 'utf8' });
eq(r.status, 1, label + ': ' + r.stdout + r.stderr); ok(/출처|provenance/i.test(r.stderr + r.stdout), label + ': 이유 출력'); ok(!existsSync(join(dir, 'hal-' + label, 'hal.sqlite')), label + ': DB 를 열기 전에 멈춘다');
}
});
await test('hal/core.approveRelease: checks.provenanceClean=false 인 릴리스는 승인할 수 없다(release_checks_failed); 기록은 되며 provenanceClean=true 는 승인된다', async () => {
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const core = await import('../hal/core.mjs');
const dir = tmpDir('aw-prov-hal-'); const d = { store: openDb(join(dir, 'hal.sqlite'), MIGRATIONS), log: () => {} };
core.registerRelease(d, { version: '9.9.1', mainCommit: HEX, builtAt: 'x', artifacts: { hal: { file: 'hal-9.9.1.tar.gz', sha256: 'z', size: 1 } }, checks: { testsPassed: true, dirty: false, provenanceClean: false, provenanceProblems: ['webmaker: workingTreeDirty'] } });
eq(core.getRelease(d, '9.9.1').status, 'registered');
await rejects(async () => core.approveRelease(d, '9.9.1', 'op'), 'release_checks_failed', '더러운 출처 승인 거절'); eq(core.getRelease(d, '9.9.1').status, 'registered');
core.registerRelease(d, { version: '9.9.2', mainCommit: HEX, builtAt: 'x', artifacts: { hal: { file: 'hal-9.9.2.tar.gz', sha256: 'z', size: 1 } }, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] } });
eq(core.approveRelease(d, '9.9.2', 'op').status, 'approved');
d.store.close();
});
const CLEAN = { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] };
const ART = { hal: { file: 'hal-x.tar.gz', sha256: 'z', size: 1 } };
await test('hal/core.approveRelease·registerRelease(approve:true): testsPassed===true · provenanceClean===true · dirty===false 를 모두 확인해야 승인한다(fail-closed) — checks 없음·testsPassed null/없음/문자열·dirty true/없음/null·provenanceClean 없음/null/false 는 모두 release_checks_failed 이고 상태는 registered 그대로', async () => {
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const core = await import('../hal/core.mjs');
const dir = tmpDir('aw-approve-'); const d = { store: openDb(join(dir, 'hal.sqlite'), MIGRATIONS), log: () => {} };
const without = (k) => { const c = { ...CLEAN }; delete c[k]; return c; };
const bads = [['checks 없음', null], ['checks 빈 객체', {}], ['testsPassed null(--skip-tests 빌드)', { ...CLEAN, testsPassed: null }], ['testsPassed 없음', without('testsPassed')], ['testsPassed 문자열 "true"', { ...CLEAN, testsPassed: 'true' }], ['testsPassed 1', { ...CLEAN, testsPassed: 1 }],
['dirty true', { ...CLEAN, dirty: true }], ['dirty 없음', without('dirty')], ['dirty null', { ...CLEAN, dirty: null }], ['dirty "false"(문자열)', { ...CLEAN, dirty: 'false' }],
['provenanceClean 없음(옛 빌드)', without('provenanceClean')], ['provenanceClean null', { ...CLEAN, provenanceClean: null }], ['provenanceClean false', { ...CLEAN, provenanceClean: false, provenanceProblems: ['webmaker: workingTreeDirty'] }]];
let i = 0;
for (const [label, checks] of bads) {
const v = `8.8.${i++}`;
core.registerRelease(d, { version: v, mainCommit: HEX, builtAt: 'x', artifacts: ART, checks });
await rejects(async () => core.approveRelease(d, v, 'op'), 'release_checks_failed', label + ' (approveRelease)'); eq(core.getRelease(d, v).status, 'registered', label + ': 상태 그대로');
await rejects(async () => core.registerRelease(d, { version: v, mainCommit: HEX, builtAt: 'x', artifacts: ART, checks, approve: true, by: 'op' }), 'release_checks_failed', label + ' (registerRelease approve:true)'); eq(core.getRelease(d, v).status, 'registered', label + ': approve:true 로 다시 등록해도 승인되지 않음');
}
core.registerRelease(d, { version: '8.9.0', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: CLEAN }); eq(core.approveRelease(d, '8.9.0', 'op').status, 'approved', '정확히 깨끗·통과한 검사만 승인');
eq(core.registerRelease(d, { version: '8.9.1', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: CLEAN, approve: true, by: 'op' }).status, 'approved', 'registerRelease approve:true 도 같은 규칙으로 승인');
d.store.close();
});
await test('hal/core.registerRelease 불변 승인: 승인된 버전은 같은 내용의 재등록만 멱등하게 받고(상태 approved 유지), artifact·checks·커밋·빌드 시각이 다른 재등록은 approve 여부와 무관하게 release_immutable 로 거절하며 아무것도 바꾸지 않는다; 승인되지 않은 버전의 내용 변경 재등록은 registered 로 남고 승인을 물려받지 않는다', async () => {
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const core = await import('../hal/core.mjs');
const dir = tmpDir('aw-immutable-'); const d = { store: openDb(join(dir, 'hal.sqlite'), MIGRATIONS), log: () => {} };
const base = { version: '6.6.0', mainCommit: HEX, builtAt: '2026-10-28T00:00:00Z', artifacts: ART, checks: CLEAN };
const snap = (v) => { const r = core.getRelease(d, v); return JSON.stringify({ status: r.status, by: r.approved_by, at: r.approved_at, main: r.main_commit, built: r.built_at, artifacts: r.artifacts, checks: r.checks, notes: r.notes }); };
eq(core.registerRelease(d, { ...base, approve: true, by: 'op' }).status, 'approved'); const s0 = snap('6.6.0');
eq(core.registerRelease(d, { ...base }).status, 'approved', '같은 내용 재등록(approve:false) 은 멱등'); eq(snap('6.6.0'), s0); eq(core.registerRelease(d, { ...base, approve: true, by: 'op2' }).status, 'approved', '같은 내용 재등록(approve:true) 도 멱등'); eq(snap('6.6.0'), s0, '승인자·시각도 그대로');
const bad = { ...CLEAN, testsPassed: null }; const otherArt = { hal: { file: 'hal-x.tar.gz', sha256: 'DIFFERENT', size: 2 } };
for (const [label, p] of [['checks 변경(불량) approve:false', { ...base, checks: bad }], ['checks 변경(불량) approve:true', { ...base, checks: bad, approve: true, by: 'op' }], ['checks 변경(깨끗) approve:true', { ...base, checks: { ...CLEAN, provenanceProblems: ['x'] }, approve: true, by: 'op' }], ['artifact 변경', { ...base, artifacts: otherArt }], ['artifact 변경 approve:true', { ...base, artifacts: otherArt, approve: true, by: 'op' }], ['커밋 변경', { ...base, mainCommit: 'b'.repeat(40) }], ['빌드 시각 변경', { ...base, builtAt: '2026-10-29T00:00:00Z' }], ['notes 변경', { ...base, notes: '다른 메모' }]]) {
await rejects(async () => core.registerRelease(d, p), 'release_immutable', label); eq(snap('6.6.0'), s0, label + ': 읽기 결과 불변');
}
// 승인되지 않은 버전: 내용이 바뀐 재등록은 받되 registered 로 남고, approve:true 여도 검사 규칙을 지나야 승인된다
core.registerRelease(d, { ...base, version: '6.6.1' }); eq(core.getRelease(d, '6.6.1').status, 'registered');
eq(core.registerRelease(d, { ...base, version: '6.6.1', checks: bad }).status, 'registered', '불량 checks 로 재등록: registered'); eq(core.getRelease(d, '6.6.1').checks.testsPassed, null, '내용은 갱신됨');
await rejects(async () => core.registerRelease(d, { ...base, version: '6.6.1', checks: bad, approve: true, by: 'op' }), 'release_checks_failed', '불량 checks + approve:true'); eq(core.getRelease(d, '6.6.1').status, 'registered'); eq(core.getRelease(d, '6.6.1').approved_by, null, '승인을 물려받지 않는다');
eq(core.registerRelease(d, { ...base, version: '6.6.1', approve: true, by: 'op' }).status, 'approved', '깨끗한 내용으로 재등록 + approve:true 는 승인');
await rejects(async () => core.registerRelease(d, { ...base, version: '6.6.1', checks: bad }), 'release_immutable', '승인 뒤에는 불변'); eq(core.getRelease(d, '6.6.1').status, 'approved'); eq(core.getRelease(d, '6.6.1').checks.testsPassed, true);
d.store.close();
});
await test('HAL CLI register-release: 승인된 버전을 다른 manifest(불량 checks·다른 artifact)로 다시 등록하면 --approve 유무와 무관하게 종료 코드 1 이고 DB 의 릴리스(상태 approved·checks·artifact)는 그대로다; 같은 manifest 재등록은 멱등', async () => {
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const { spawnSync: run } = await import('node:child_process');
const dir = tmpDir('aw-immutable-cli-'); const halData = join(dir, 'hal'); mkdirSync(halData, { recursive: true });
const cli = (args) => run(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: halData, PORT: '0' }, encoding: 'utf8', timeout: 30000 });
const mf = (name, over = {}) => { const p = join(dir, name); mkdirSync(p, { recursive: true }); writeFileSync(join(p, 'hal-5.5.0.tar.gz'), 'tgz'); const sha = createHash('sha256').update('tgz').digest('hex'); writeFileSync(join(p, 'manifest.json'), JSON.stringify({ version: '5.5.0', mainCommit: HEX, builtAt: '2026-10-28T00:00:00Z', artifacts: { hal: { file: 'hal-5.5.0.tar.gz', sha256: sha, size: 3 } }, checks: CLEAN, ...over })); return join(p, 'manifest.json'); };
const m1 = mf('m1'); let r = cli(['register-release', '--manifest', m1, '--approve', '--by', 'test']); eq(r.status, 0, r.stdout + r.stderr); ok(/approved/.test(r.stdout), r.stdout);
const read = () => { const db = openDb(join(halData, 'hal.sqlite'), MIGRATIONS); const row = db.get('SELECT status, approved_by, checks_json, artifacts_json, main_commit FROM releases WHERE version=?', '5.5.0'); db.close(); return JSON.stringify(row); };
const s0 = read(); ok(/approved/.test(s0));
r = cli(['register-release', '--manifest', m1, '--approve', '--by', 'test2']); eq(r.status, 0, '같은 manifest 재등록은 멱등: ' + r.stdout + r.stderr); eq(read(), s0);
r = cli(['register-release', '--manifest', m1]); eq(r.status, 0, r.stdout + r.stderr); eq(read(), s0);
const m2 = mf('m2', { checks: { ...CLEAN, testsPassed: null } });
for (const args of [['register-release', '--manifest', m2, '--approve', '--by', 'test'], ['register-release', '--manifest', m2]]) { r = cli(args); eq(r.status, 1, args.join(' ') + ': ' + r.stdout + r.stderr); ok(/불변|immutable|승인된/.test(r.stderr + r.stdout), args.join(' ') + ': 이유 출력: ' + (r.stderr + r.stdout).slice(0, 300)); eq(read(), s0, args.join(' ') + ': DB 불변'); }
const m3 = mf('m3'); writeFileSync(join(dir, 'm3', 'hal-5.5.0.tar.gz'), 'other'); writeFileSync(m3, JSON.stringify({ ...JSON.parse(readFileSync(m3, 'utf8')), artifacts: { hal: { file: 'hal-5.5.0.tar.gz', sha256: createHash('sha256').update('other').digest('hex'), size: 5 } } }));
r = cli(['register-release', '--manifest', m3, '--approve', '--by', 'test']); eq(r.status, 1, 'artifact 변경: ' + r.stdout + r.stderr); eq(read(), s0, 'artifact 변경: DB 불변');
});
await test('artifact 설치 불변·fail-closed(HAL CLI register-release): 승인된 버전 A 가 있을 때 같은 버전으로 다른 바이트 B(선언 해시가 B 로 맞는 경우·A 로 거짓인 경우)를 --approve 유무와 무관하게 등록하면 종료 1 이고 대상 파일의 바이트·해시와 DB 가 A 그대로다; 새 버전의 선언 해시 불일치는 파일을 설치하지 않으며, 같은 A 재등록은 멱등', async () => {
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const { spawnSync: run } = await import('node:child_process');
const dir = tmpDir('aw-artifact-cli-'); const halData = join(dir, 'hal'); mkdirSync(halData, { recursive: true });
const cli = (args) => run(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: halData, PORT: '0' }, encoding: 'utf8', timeout: 30000 });
const sha = (b) => createHash('sha256').update(b).digest('hex');
const mf = (name, bytes, { declaredSha, version = '4.4.0', checks = CLEAN } = {}) => { const p = join(dir, name); mkdirSync(p, { recursive: true }); writeFileSync(join(p, `hal-${version}.tar.gz`), bytes); writeFileSync(join(p, 'manifest.json'), JSON.stringify({ version, mainCommit: HEX, builtAt: '2026-10-28T00:00:00Z', artifacts: { hal: { file: `hal-${version}.tar.gz`, sha256: declaredSha || sha(bytes), size: bytes.length } }, checks })); return join(p, 'manifest.json'); };
const target = join(halData, 'artifacts', '4.4.0', 'hal-4.4.0.tar.gz');
const read = () => { const db = openDb(join(halData, 'hal.sqlite'), MIGRATIONS); const row = db.get('SELECT status, approved_by, artifacts_json FROM releases WHERE version=?', '4.4.0'); db.close(); return JSON.stringify(row); };
const A = Buffer.from('artifact-A-bytes'); const B = Buffer.from('artifact-B-bytes-different');
let r = cli(['register-release', '--manifest', mf('mA', A), '--approve', '--by', 'test']); eq(r.status, 0, r.stdout + r.stderr); eq(readFileSync(target).toString(), 'artifact-A-bytes'); const s0 = read(); ok(/approved/.test(s0) && s0.includes(sha(A)));
for (const [label, manifest, args] of [['B 유효 해시 + approve', mf('mB1', B), ['--approve', '--by', 'test']], ['B 유효 해시', mf('mB2', B), []], ['B 바이트에 A 해시(거짓 선언) + approve', mf('mB3', B, { declaredSha: sha(A) }), ['--approve', '--by', 'test']], ['B 바이트에 A 해시(거짓 선언)', mf('mB4', B, { declaredSha: sha(A) }), []]]) {
r = cli(['register-release', '--manifest', manifest, ...args]); eq(r.status, 1, label + ': ' + r.stdout + r.stderr); ok(/불변|immutable|해시|hash|승인된/.test(r.stderr + r.stdout), label + ': 이유: ' + (r.stderr + r.stdout).slice(0, 300));
eq(readFileSync(target).toString(), 'artifact-A-bytes', label + ': 대상 바이트 A 그대로'); eq(sha(readFileSync(target)), sha(A), label + ': 대상 해시 A'); eq(read(), s0, label + ': DB 그대로');
ok(!readdirSync(join(halData, 'artifacts', '4.4.0')).some((f) => /\.tmp/.test(f)), label + ': 임시 파일 없음');
}
r = cli(['register-release', '--manifest', mf('mA2', A), '--approve', '--by', 'test2']); eq(r.status, 0, '같은 A 재등록 멱등: ' + r.stdout + r.stderr); eq(readFileSync(target).toString(), 'artifact-A-bytes'); eq(read(), s0);
// 새(미승인) 버전: 선언 해시가 바이트와 다르면 설치도 등록도 하지 않는다
r = cli(['register-release', '--manifest', mf('mN', B, { version: '4.4.1', declaredSha: sha(A) })]); eq(r.status, 1, '새 버전 해시 불일치: ' + r.stdout + r.stderr); ok(!existsSync(join(halData, 'artifacts', '4.4.1', 'hal-4.4.1.tar.gz')), '파일 미설치'); ok(!existsSync(join(halData, 'artifacts', '4.4.1')) || !readdirSync(join(halData, 'artifacts', '4.4.1')).length, '임시 파일 없음');
const db = openDb(join(halData, 'hal.sqlite'), MIGRATIONS); eq(db.get('SELECT version FROM releases WHERE version=?', '4.4.1'), undefined); db.close();
});
await test('artifact 설치 불변·fail-closed(scripts/release.mjs register): 승인된 버전 A 위에 다른 바이트 B(유효 해시·거짓 해시) 등록은 --approve 유무와 무관하게 종료 1, 대상 바이트·DB 는 A 그대로; 같은 A 는 멱등', () => {
const dir = tmpDir('aw-artifact-rel-'); const ver = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; const halData = join(dir, 'hal');
const sha = (b) => createHash('sha256').update(b).digest('hex');
const dist = (name, bytes, declaredSha) => { const p = join(dir, name, ver); mkdirSync(p, { recursive: true }); writeFileSync(join(p, `hal-${ver}.tar.gz`), bytes); writeFileSync(join(p, 'manifest.json'), JSON.stringify({ version: ver, mainCommit: HEX, branch: 'main', builtAt: 'x', artifacts: { hal: { file: `hal-${ver}.tar.gz`, sha256: declaredSha || sha(bytes), size: bytes.length } }, checks: CLEAN, imported: [] })); return p; };
const reg = (d, extra = []) => spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'register', '--hal-data', halData, '--dist', d, ...extra, '--by', 'test'], { cwd: ROOT, encoding: 'utf8' });
const target = join(halData, 'artifacts', ver, `hal-${ver}.tar.gz`); const A = Buffer.from('rel-A'); const B = Buffer.from('rel-B-other');
let r = reg(dist('dA', A), ['--approve']); eq(r.status, 0, r.stdout + r.stderr); eq(readFileSync(target).toString(), 'rel-A');
const { openDb } = { openDb: null }; void openDb;
for (const [label, d, extra] of [['B 유효 + approve', dist('dB1', B), ['--approve']], ['B 유효', dist('dB2', B), []], ['B 에 A 해시 + approve', dist('dB3', B, sha(A)), ['--approve']], ['B 에 A 해시', dist('dB4', B, sha(A)), []]]) {
r = reg(d, extra); eq(r.status, 1, label + ': ' + r.stdout + r.stderr); eq(readFileSync(target).toString(), 'rel-A', label + ': 대상 바이트 A 그대로'); ok(!readdirSync(join(halData, 'artifacts', ver)).some((f) => /\.tmp/.test(f)), label + ': 임시 파일 없음');
}
r = reg(dist('dA2', A), ['--approve']); eq(r.status, 0, '같은 A 멱등: ' + r.stdout + r.stderr); eq(readFileSync(target).toString(), 'rel-A');
});
await test('HAL 운영자 API POST /ops/api/releases/:version/approve 도 같은 규칙 — 검사 기록이 없는 릴리스는 release_checks_failed 로 거절, 깨끗한 릴리스는 승인', async () => {
const { createHalDeps } = await import('../hal/deps.mjs'); const { buildApp } = await import('../hal/app.mjs'); const core = await import('../hal/core.mjs'); const ops = await import('../hal/ops.mjs'); const { invoke } = await import('../lib/http.mjs');
const dataDir = join(tmpDir('aw-approve-app-'), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } }); const app = buildApp(d);
const opsPw = randomBytes(12).toString('base64url'); // 실행마다 만드는 비밀번호(소스에 고정 문자열 없음)
ops.createAccount(d, { login: 'ozops', password: opsPw, displayName: '오즈' }); const login = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: opsPw } }); ok(login.json.ok, JSON.stringify(login.json));
const as = (method, path, body) => invoke(app, method, path, { body, cookies: { aw_ops: login.cookies.aw_ops }, headers: { 'x-csrf': login.json.csrf } });
core.registerRelease(d, { version: '7.7.0', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: null });
core.registerRelease(d, { version: '7.7.1', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: { testsPassed: true, dirty: false } });
core.registerRelease(d, { version: '7.7.2', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: CLEAN });
for (const v of ['7.7.0', '7.7.1']) { const r = await as('POST', `/ops/api/releases/${v}/approve`); ok(r.status >= 400 && r.status < 500, `${v}: 거절 ${r.status} ${r.text.slice(0, 200)}`); ok(/release_checks_failed/.test(r.text), `${v}: 이유 코드: ${r.text.slice(0, 200)}`); eq(core.getRelease(d, v).status, 'registered'); }
const okr = await as('POST', '/ops/api/releases/7.7.2/approve'); eq(okr.status, 200, okr.text.slice(0, 200)); eq(core.getRelease(d, '7.7.2').status, 'approved');
d.store.close();
});
await test('release.mjs register --approve: testsPassed 가 true 가 아니면(--skip-tests 빌드의 null·없음) · dirty 가 false 가 아니면(true·없음) 승인하지 않고 DB 를 열기 전에 종료 1; 세 검사가 모두 깨끗한 manifest 만 승인된다', () => {
const dir = tmpDir('aw-approve-reg-'); const ver = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
const run = (label, checks) => { const dist = join(dir, 'dist-' + label, ver); mkdirSync(dist, { recursive: true }); writeFileSync(join(dist, 'manifest.json'), JSON.stringify({ version: ver, mainCommit: HEX, branch: 'main', builtAt: 'x', artifacts: {}, checks, imported: [] })); return { r: spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'register', '--hal-data', join(dir, 'hal-' + label), '--dist', dist, '--approve', '--by', 'test'], { cwd: ROOT, encoding: 'utf8' }), hal: join(dir, 'hal-' + label) }; };
for (const [label, checks, re] of [['tests-null', { ...CLEAN, testsPassed: null }, /테스트|testsPassed/], ['tests-missing', (() => { const c = { ...CLEAN }; delete c.testsPassed; return c; })(), /테스트|testsPassed/], ['dirty-true', { ...CLEAN, dirty: true }, /dirty|깨끗/], ['dirty-missing', (() => { const c = { ...CLEAN }; delete c.dirty; return c; })(), /dirty|깨끗/]]) {
const { r, hal } = run(label, checks); eq(r.status, 1, label + ': ' + r.stdout + r.stderr); ok(re.test(r.stderr + r.stdout), label + ': 이유 출력: ' + (r.stderr + r.stdout).slice(0, 300)); ok(!existsSync(join(hal, 'hal.sqlite')), label + ': DB 를 열기 전에 멈춘다');
}
const { r, hal } = run('clean', CLEAN); eq(r.status, 0, 'clean: ' + r.stdout + r.stderr); ok(/approved/.test(r.stdout), 'clean: 승인 등록: ' + r.stdout); ok(existsSync(join(hal, 'hal.sqlite')));
});
done();