[verified] feat: add Webmaker visual editor v0.2.0
This commit is contained in:
+1
-1
@@ -46,7 +46,7 @@ export function buildFakeRelease(base, version, { withWebmaker = false } = {}) {
|
||||
const file = `${m}-${version}.tar.gz`; tarGz(src, join(dist, file));
|
||||
artifacts[m] = { file, sha256: createHash('sha256').update(readFileSync(join(dist, file))).digest('hex'), size: statSync(join(dist, file)).size };
|
||||
}
|
||||
const manifest = { version, mainCommit: 'e2e0000000000000000000000000000000000000', branch: 'e2e', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false } };
|
||||
const manifest = { version, mainCommit: 'e2e0000000000000000000000000000000000000', branch: 'e2e', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] } }; // register-release --approve 는 세 검사가 모두 깨끗해야 승인한다(0.2.0)
|
||||
writeFileSync(join(dist, 'manifest.json'), JSON.stringify(manifest, null, 1)); return { dist, manifest: join(dist, 'manifest.json'), artifacts };
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ await test('module-deploy: 운영자 API 와 같은 멱등 규칙으로 module.d
|
||||
const dir = makeDataDir(); const d = deps(dir);
|
||||
const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
|
||||
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id]).code, 1, '릴리스가 없으면 거부');
|
||||
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' });
|
||||
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] }, approve: true, by: 'test' }); // 승인은 세 검사가 모두 깨끗해야 한다(0.2.0)
|
||||
eq(cli(dir, ['module-deploy', '--module', 'nope', '--version', '0.1.1', '--servers', srv.id]).code, 1, '알 수 없는 모듈 거부');
|
||||
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', 'srv_none']).code, 1, '없는 서버 거부');
|
||||
const r = cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--reason', 'portal → hal21 link']);
|
||||
@@ -49,7 +49,7 @@ await test('module-deploy: 운영자 API 와 같은 멱등 규칙으로 module.d
|
||||
|
||||
await test('CLI 가 넣은 작업을 같은 데이터의 서비스 측 작업 엔진이 집어 실행한다(artifact 없음·부트스트랩 미완료 → precheck 실패로 끝남, 큐에 남지 않음)', async () => {
|
||||
const dir = makeDataDir(); const d = deps(dir); const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
|
||||
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' });
|
||||
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] }, approve: true, by: 'test' }); // 승인은 세 검사가 모두 깨끗해야 한다(0.2.0)
|
||||
const out = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop());
|
||||
d.jobsRunning = true; const n = await d.jobs.drain(); ok(n >= 1, '서비스 측 엔진이 큐 작업을 집었다');
|
||||
const j = d.jobs.get(out.jobId); eq(j.status, 'failed'); ok(['artifact_missing', 'bootstrap_incomplete'].includes(j.error_code), `precheck 실패로 끝남: ${j.error_code}`); eq(d.jobs.list({ status: 'queued' }).length, 0);
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ await test('모듈 배포: 릴리스 승인·HAL 먼저·대상별 자식 작업
|
||||
const h = makeHal(); bootstrapDone(h.d); const a1 = await registerAgent(h, { name: 'w1', role: 'control' }); const a2 = await registerAgent(h, { name: 'w2', role: 'web' });
|
||||
const art = join(h.dataDir, 'artifacts', '0.1.1'); mkdirSync(art, { recursive: true }); writeFileSync(join(art, 'portal-0.1.1.tar.gz'), 'x'); writeFileSync(join(art, 'hal-0.1.1.tar.gz'), 'y');
|
||||
const sum = (f) => createHash('sha256').update(f).digest('hex');
|
||||
core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abc', builtAt: new Date().toISOString(), artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: sum('x') }, hal: { file: 'hal-0.1.1.tar.gz', sha256: sum('y') } } });
|
||||
core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abc', builtAt: new Date().toISOString(), artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: sum('x') }, hal: { file: 'hal-0.1.1.tar.gz', sha256: sum('y') } }, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] } }); // 승인(아래)은 세 검사가 모두 깨끗해야 한다(0.2.0)
|
||||
const enq = (module, version = '0.1.1', ids = [a1.serverId, a2.serverId]) => h.d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version, serverIds: ids }, createdBy: 'op', actorType: 'operator' }).job;
|
||||
let j = enq('portal'); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'release_not_approved');
|
||||
core.approveRelease(h.d, '0.1.1', 'op'); core.setBootstrap(h.d, { hal_running_version: '0.1.0' });
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* test/webmaker-visual-editor.test.mjs — Ariweb Suite 0.2.0: 도입 모듈 Webmaker 1.8.0 의 직접 편집(시각 편집기) 회귀 검증.
|
||||
* Suite 쪽에서 확인할 것: ① 도입된 순수 모듈(modules/webmaker/lib/visual-editor.js · public/visual-editor-core.js)이 Suite 체크아웃에서 그대로 동작하고,
|
||||
* ② Suite 전용 변경(동시 편집 rev 충돌 409, [Ariweb Suite 변경])이 직접 편집 저장 경로에도 실제로 적용되며(서버를 띄워 확인), ③ 화면에 필수 접근성 조작이 있다.
|
||||
* 브라우저 없이 Node 로만 검사한다. Webmaker 자체 전체 테스트(modules/webmaker: npm test)는 별도로 실행한다.
|
||||
*/
|
||||
import { test, eq, ok, done, sleep } from '../lib/test.mjs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, mkdtempSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
// 비밀값(설정 토큰·비밀번호)은 실행마다 만들고 변수로만 넘긴다 — 소스에 고정 문자열로 두지 않고 출력하지도 않는다
|
||||
const SETUP_TOKEN = randomBytes(24).toString('base64url'); const PW = { alice: randomBytes(12).toString('base64url'), root: randomBytes(12).toString('base64url') };
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WM = join(ROOT, 'modules', 'webmaker');
|
||||
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.0 이다', () => {
|
||||
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
||||
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.2.0');
|
||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
||||
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
||||
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||
else { eq(imported.commit, pin, '깨끗한 기록은 스크립트의 고정 커밋과 같다'); eq(imported.upstream && imported.upstream.headCommit, pin, 'headCommit = pin'); }
|
||||
eq(imported.upstream && imported.upstream.version, '1.8.0');
|
||||
ok(imported.files.some((f) => f.from === 'lib/visual-editor.js'), '도입 기록에 새 파일');
|
||||
});
|
||||
|
||||
await test('lib/visual-editor: 캔버스 변환은 스크립트를 실행하지 않게 하고, 저장 검증은 새 스크립트·핸들러를 거절한다', () => {
|
||||
const ve = require(join(WM, 'lib', 'visual-editor.js'));
|
||||
const page = '<!doctype html><html><head><title>t</title><script>boot()</script></head><body><h1 onclick="a()">안녕</h1><p>본문</p></body></html>';
|
||||
const canvas = ve.prepareCanvas(page, { baseHref: 'http://h/x/', session: 's', nonce: 'n', parentOrigin: 'http://h', runtime: '1' });
|
||||
ok(/<script type="text\/x-wm-ve-disabled">boot\(\)<\/script>/.test(canvas)); ok(canvas.includes('data-wm-ve-on-click="a()"') && !/\sonclick=/.test(canvas));
|
||||
const body = ve.splitDocument(canvas).body.replace('본문', '본문 수정');
|
||||
const r = ve.validateVisualSave({ original: page, body, maxBytes: 1e6 });
|
||||
ok(r.html.includes('<h1 onclick="a()">안녕</h1><p>본문 수정</p>') && r.html.startsWith('<!doctype html><html><head><title>t</title><script>boot()</script></head><body>'));
|
||||
let err = null; try { ve.validateVisualSave({ original: page, body: body + '<script>x()</script>', maxBytes: 1e6 }); } catch (e) { err = e; } eq(err && err.status, 400); ok(/스크립트/.test(err.message));
|
||||
});
|
||||
|
||||
await test('visual-editor-core: 실행 취소/다시 실행·저장 상태·메시지 검증·구성 요소가 Suite 체크아웃에서도 같다', () => {
|
||||
const core = require(join(WM, 'public', 'visual-editor-core.js'));
|
||||
const h = core.createHistory(10); h.reset('a'); h.push('b'); eq(h.undo(), 'a'); eq(h.redo(), 'b');
|
||||
const s = core.createSaveState(); s.markDirty(); ok(s.beginSave()); s.saveOk(); eq(s.state(), 'saved');
|
||||
const win = {}; ok(core.validateMessage({ source: win, origin: 'null', data: core.makeMessage('s', 'changed', {}) }, { expectedSource: win, expectedOrigin: 'null', session: 's', direction: 'fromCanvas' }));
|
||||
ok(!core.validateMessage({ source: win, origin: 'null', data: core.makeMessage('t', 'changed', {}) }, { expectedSource: win, expectedOrigin: 'null', session: 's', direction: 'fromCanvas' }));
|
||||
for (const id of ['section', 'heading', 'paragraph', 'button', 'image', 'divider', 'spacer']) ok(core.isSafeComponentHtml(core.componentHtml(id)), id);
|
||||
});
|
||||
|
||||
await test('화면(index.html)에 직접 편집 전환·저장·실행 취소·다시 실행·버리기·팔레트·속성 조작이 접근성 속성과 함께 있고 Suite 배너는 남아 있다', () => {
|
||||
const html = readFileSync(join(WM, 'public', 'index.html'), 'utf8');
|
||||
ok(/<button id="btnVisual"[^>]*aria-pressed="false"/.test(html), '직접 편집 전환 버튼');
|
||||
for (const id of ['veSave', 'veUndo', 'veRedo', 'veDiscard', 'veClose', 'veState', 'vePalette', 'veInspector']) ok(html.includes(`id="${id}"`), id);
|
||||
ok(/id="veState"[^>]*aria-live="polite"/.test(html), '저장 상태는 aria-live');
|
||||
ok(html.includes('/visual-editor-core.js') && html.includes('/visual-editor-ui.js'));
|
||||
ok(html.includes('id="ariwebBanner"'), 'Suite 전용 배너 보존');
|
||||
const app = readFileSync(join(WM, 'public', 'app.js'), 'utf8'); ok(app.includes('[Ariweb Suite 변경] 포털 SSO 진입') && app.includes('VisualEditor'), 'app.js 에 Suite 변경과 편집기 연결이 함께');
|
||||
});
|
||||
|
||||
await test('server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버)', async () => {
|
||||
const PORT = 4500 + Math.floor(Math.random() * 90); const tmp = mkdtempSync(join(tmpdir(), 'aw-wm-ve-')); const B = `http://127.0.0.1:${PORT}`; const jar = {};
|
||||
writeFileSync(join(tmp, 'setup.token'), SETUP_TOKEN + '\n', { mode: 0o600 });
|
||||
// 사이트 생성이 띄우는 AI 작업에 개발 PC 의 진짜 claude 가 쓰이지 않게(비용·시간) 바로 실패하는 가짜 실행 파일을 쓴다
|
||||
const fake = join(tmp, 'fake-claude.sh'); writeFileSync(fake, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
const child = spawn(process.execPath, [join(WM, 'server.js')], { env: { ...process.env, CLAUDE_BIN: fake, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: join(tmp, 'sites'), SITES_ROOT: join(tmp, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
|
||||
try {
|
||||
for (let i = 0; i < 100 && !/listening on/.test(out); i++) await sleep(100);
|
||||
ok(/listening on/.test(out), out.slice(-300));
|
||||
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', referer: `${B}/`, ...(init.headers || {}) }; if (jar[who]) headers.cookie = jar[who]; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b }; };
|
||||
eq((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice, name: 'Alice' }) })).status, 200);
|
||||
eq((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: SETUP_TOKEN }) }, 's')).status, 200);
|
||||
ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: PW.root }) }, 's')).status < 300);
|
||||
eq((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
|
||||
eq((await api('/api/admin/credits/alice', { method: 'POST', body: JSON.stringify({ type: 'add', amount: 50000, memo: 'test' }) }, 'r')).status, 200);
|
||||
const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'VE', purpose: 'p', content: 'c' }) }); ok(mk.status < 300, JSON.stringify(mk.b).slice(0, 200)); const id = mk.b.id;
|
||||
mkdirSync(join(tmp, 'www', id), { recursive: true }); writeFileSync(join(tmp, 'www', id, 'index.html'), '<!doctype html><html><head><title>t</title></head><body><h1>제목</h1></body></html>');
|
||||
for (let i = 0; i < 100 && (await api(`/api/sites/${id}`)).b.running; i++) await sleep(100); // 생성 때 시작된(실패하는) AI 작업이 끝날 때까지
|
||||
const g = await api(`/api/sites/${id}/visual/index.html`); eq(g.status, 200); ok(typeof g.b.rev === 'number', '캔버스 응답에 rev');
|
||||
const stale = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>충돌</h1>', baseHash: g.b.hash, rev: g.b.rev + 5 }) });
|
||||
eq(stale.status, 409, JSON.stringify(stale.b)); ok(/다른 사용자|먼저 저장/.test(stale.b.error)); ok(stale.b.conflict && stale.b.conflict.currentRev === g.b.rev, 'rev 충돌 요약');
|
||||
const okPut = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) });
|
||||
eq(okPut.status, 200, JSON.stringify(okPut.b)); ok(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8').includes('<h1>저장</h1>'));
|
||||
const s = await api(`/api/sites/${id}`); eq(s.b.versions[s.b.versions.length - 1].kind, 'visual-edit'); ok(s.b.rev > g.b.rev, '저장 뒤 rev 증가');
|
||||
|
||||
// ---- 검토 수정(독립 검토 차단 항목 7): 직접 편집 저장은 유한한 숫자 rev 가 필수 — 없음·문자열·null·NaN 문자열은 400(fail-closed), 파일은 그대로 ----
|
||||
const g2 = await api(`/api/sites/${id}/visual/index.html`); eq(g2.status, 200); const before = readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8');
|
||||
for (const [label, body] of [['rev 없음', { body: '<h1>rev 없음</h1>', baseHash: g2.b.hash }], ['문자열 rev', { body: '<h1>문자열</h1>', baseHash: g2.b.hash, rev: String(g2.b.rev) }], ['null rev', { body: '<h1>null</h1>', baseHash: g2.b.hash, rev: null }], ['NaN 문자열', { body: '<h1>NaN</h1>', baseHash: g2.b.hash, rev: 'NaN' }], ['빈 문자열', { body: '<h1>빈</h1>', baseHash: g2.b.hash, rev: '' }]]) {
|
||||
const r = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
eq(r.status, 400, `${label}: ${JSON.stringify(r.b)}`); ok(/rev/.test(r.b.error), label + ' 안내에 rev'); eq(r.b.revRequired, true, label);
|
||||
}
|
||||
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, 'rev 없는 저장은 파일을 바꾸지 않는다');
|
||||
// 다른 파일의 저장으로 rev 가 오르면(cross-file) 옛 rev 로 보낸 저장은 409 — 파일 hash 가 맞아도 조용히 덮어쓰지 않는다
|
||||
writeFileSync(join(tmp, 'www', id, 'about.html'), '<!doctype html><html><head><title>a</title></head><body><p>소개</p></body></html>');
|
||||
const ga = await api(`/api/sites/${id}/visual/about.html`); eq(ga.status, 200); eq(ga.b.rev, g2.b.rev, '같은 사이트 rev');
|
||||
const saveA = await api(`/api/sites/${id}/visual/about.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>소개 수정</p>', baseHash: ga.b.hash, rev: ga.b.rev }) }); eq(saveA.status, 200, JSON.stringify(saveA.b)); ok(saveA.b.rev > ga.b.rev);
|
||||
const cross = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>교차</h1>', baseHash: g2.b.hash, rev: g2.b.rev }) });
|
||||
// currentRev 는 saveA 응답의 rev 이상(원격 저장소가 없으면 gitRecord 가 뒤늦게 retryPush → store.save 로 rev 를 한 번 더 올릴 수 있다)
|
||||
eq(cross.status, 409, 'about.html 저장으로 오른 rev: ' + JSON.stringify(cross.b)); ok(cross.b.conflict && cross.b.conflict.currentRev >= saveA.b.rev && cross.b.conflict.yourRev === g2.b.rev, '충돌 요약(현재 rev·내 rev): ' + JSON.stringify(cross.b.conflict));
|
||||
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, '교차 충돌 저장은 파일을 바꾸지 않는다');
|
||||
|
||||
// ---- 검토 수정(차단 항목 6, Suite 쪽): 사이트 기록 저장이 실패하면 되돌리고 500 — rev 도 저장 전 값 그대로(되돌리기 저장은 rev 를 올리지 않는다), 같은 rev·baseHash 로 재시도 성공 ----
|
||||
const g3 = await api(`/api/sites/${id}/visual/index.html`); const rev3 = (await api(`/api/sites/${id}`)).b.rev; eq(g3.b.rev, rev3);
|
||||
chmodSync(join(tmp, 'sites'), 0o555);
|
||||
let fail; try { fail = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>기록 실패</h1>', baseHash: g3.b.hash, rev: g3.b.rev }) }); } finally { chmodSync(join(tmp, 'sites'), 0o755); }
|
||||
eq(fail.status, 500, JSON.stringify(fail.b)); ok(/되돌/.test(fail.b.error));
|
||||
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, '페이지 되돌림'); eq((await api(`/api/sites/${id}`)).b.rev, rev3, '실패한 저장은 rev 를 바꾸지 않는다');
|
||||
const retry = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>재시도</h1>', baseHash: g3.b.hash, rev: g3.b.rev }) }); eq(retry.status, 200, '같은 rev·baseHash 로 재시도: ' + JSON.stringify(retry.b)); ok(retry.b.rev > rev3);
|
||||
} finally { try { child.kill('SIGTERM'); } catch (_) {} }
|
||||
});
|
||||
|
||||
done();
|
||||
Reference in New Issue
Block a user