'use strict';
// v1.8.0 직접 편집(시각 편집기) 서비스 단위 검증 — 빈 데이터로 서버를 띄워 API 로 확인한다.
// 권한(소유자만)·CSRF/출처·HTML 페이지만·경로 탈출·캔버스 문서의 스크립트 비실행 계약·저장(파일 갱신·버전 기록·캐시 미차감·AI 수정 횟수 미계수)·
// 저장 PUT 은 GET 이 준 rev 를 함께 보낸다 — 단독 Webmaker 는 rev 0 을 주고 무시하지만 Ariweb Suite 의 같은 서버 코드는 유한한 숫자 rev 를 필수로 요구한다(이 파일은 두 저장소에서 byte-identical 로 돈다).
// 오래된 저장 거절(409)·AI 작업 중 저장 거절(409, 가짜 claude 실행 파일로 작업을 멈춰 둔 두 번째 서버)·잘못된 본문·화면의 필수 조작을 본다.
const test = require('node:test'); const assert = require('node:assert/strict');
const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http'); const net = require('net'); const crypto = require('crypto'); const { spawn, execFileSync } = require('child_process');
// 비밀값(설정 토큰·비밀번호)은 실행마다 만들고 변수로만 넘긴다 — 소스에 고정 문자열로 두지 않고 출력하지도 않는다
const SETUP_TOKEN = crypto.randomBytes(24).toString('base64url'); const PW = { alice: crypto.randomBytes(12).toString('base64url'), bob: crypto.randomBytes(12).toString('base64url'), root: crypto.randomBytes(12).toString('base64url') };
const ROOT = path.join(__dirname, '..');
const PAGE = '\n
직접 편집\n
제목
문단
소개\n';
/** OS 가 비어 있다고 준 루프백 포트 하나(ephemeral 범위 — 개발 PC 의 다른 서버(예: 브라우저 게이트용 4318)와 겹치지 않는다) */
function freePort() {
return new Promise((resolve, reject) => { const srv = net.createServer(); srv.unref(); srv.on('error', reject); srv.listen(0, '127.0.0.1', () => { const { port } = srv.address(); srv.close(() => resolve(port)); }); });
}
/** 서버 한 대를 띄우고 가입·관리자·캐시 지급까지 끝낸 뒤 호출 도우미를 돌려준다. 포트는 OS 가 준 빈 포트를 쓰고, 그 사이 누가 차지해 EADDRINUSE 가 나면 다른 포트로 다시 띄운다(최대 5회) */
async function boot(label, extraEnv = {}, reuseTmp = null, preparedTmp = null) {
const tmp = reuseTmp || preparedTmp || fs.mkdtempSync(path.join(os.tmpdir(), `wm-ve-${label}-`)); const jar = {}; // preparedTmp: 기동 전에 설정 파일(배포 대상 등)을 미리 둔 새 데이터 폴더
if (!reuseTmp) fs.writeFileSync(path.join(tmp, 'setup.token'), SETUP_TOKEN + '\n', { mode: 0o600 });
let child = null; let out = ''; let PORT = 0; let B = '';
for (let attempt = 0; attempt < 5; attempt++) {
PORT = await freePort(); B = `http://127.0.0.1:${PORT}`; out = '';
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0', ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
const t0 = Date.now();
while (!/listening on/.test(out) && !/EADDRINUSE/.test(out) && child.exitCode === null && Date.now() - t0 < 15000) await new Promise((r) => setTimeout(r, 50));
if (/listening on/.test(out)) break;
try { child.kill('SIGTERM'); } catch (_) {}
if (!/EADDRINUSE/.test(out)) break; // 포트 충돌이 아닌 기동 실패는 다시 시도하지 않고 아래에서 드러낸다
}
assert.match(out, /listening on/, `서버 기동 실패(${label}, 포트 ${PORT}): ${out.slice(-600)}`);
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) }; if (jar[who]) headers.cookie = jar[who]; if (init.referer !== null) headers.referer = init.referer || `${B}/`; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc && init.keep !== false) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b, headers: r.headers }; };
if (reuseTmp) { // 같은 데이터로 다시 기동(재기동 검증): 계정은 이미 있으므로 로그인만
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice }) }, 'a')).status, 200);
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
} else {
assert.equal((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice, name: 'Alice' }) }, 'a')).status, 200);
assert.equal((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'bob', password: PW.bob, name: 'Bob' }) }, 'b')).status, 200);
assert.equal((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: SETUP_TOKEN }) }, 's')).status, 200);
assert.ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: PW.root }) }, 's')).status < 300);
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
assert.equal((await api('/api/admin/credits/alice', { method: 'POST', body: JSON.stringify({ type: 'add', amount: 50000, memo: 'test' }) }, 'r')).status, 200);
}
/** alice 의 사이트를 만들고 index.html 을 직접 써 둔다(AI 엔진 없이 미리보기 가능한 페이지) */
const mkSite = async (html = PAGE) => { const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'VE site', purpose: 'p', content: 'c' }) }, 'a'); assert.ok(mk.status < 300, JSON.stringify(mk.b).slice(0, 200)); const id = mk.b.id; const dir = path.join(tmp, 'www', id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.html'), html); fs.writeFileSync(path.join(dir, 'style.css'), 'body{}'); return { id, dir }; };
return { B, tmp, child, api, mkSite, jar, out: () => out };
}
let S; let S2; let S3; let S4; let S5;
// 기본 서버는 "바로 실패하는" 가짜 claude 를 쓴다 — 개발 PC 에 설치된 진짜 claude 가 사이트 생성 작업으로 실행되지 않게(비용·시간) 하고, 생성 직후 작업이 곧 끝나 저장 검증을 방해하지 않게 한다
const FAKE_FAIL = path.join(os.tmpdir(), `fake-claude-fail-${process.pid}.sh`);
const BARE = path.join(os.tmpdir(), `wm-ve-origin-${process.pid}.git`); // 원격 Git 트랜잭션 검증용 bare 저장소(pre-receive 훅으로 푸시 거부 주입)
const GITWRAP = path.join(os.tmpdir(), `wm-ve-gitwrap-${process.pid}`); // 서버가 쓰는 git 래퍼(푸시 뒤 클라이언트 오류·부분 푸시·불통을 표식 파일로 주입)
const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
test.before(async () => { fs.writeFileSync(FAKE_FAIL, '#!/bin/sh\necho "fake claude: not available" >&2\nexit 1\n', { mode: 0o755 }); S = await boot('main', { CLAUDE_BIN: FAKE_FAIL }); });
test.after(() => { for (const s of [S, S2, S3, S4, S5]) { try { s && s.child.kill('SIGTERM'); } catch (_) {} } try { fs.unlinkSync(FAKE_FAIL); } catch (_) {} try { fs.rmSync(BARE, { recursive: true, force: true }); } catch (_) {} try { fs.rmSync(GITWRAP, { recursive: true, force: true }); } catch (_) {} });
/** 사이트 생성이 띄운(실패하는) AI 작업이 끝날 때까지 기다린다 */
async function idle(s, id) { for (let i = 0; i < 100; i++) { const r = await s.api(`/api/sites/${id}`, {}, 'a'); if (!r.b.running) return; await new Promise((r2) => setTimeout(r2, 100)); } throw new Error('AI 작업이 끝나지 않음'); }
test('권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절', async () => {
const { id } = await S.mkSite(); await idle(S, id); global.__ve = { id };
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a')).status, 200, '소유자 GET');
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'b')).status, 404, '다른 사용자');
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '
', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(big.status, 413);
const nojson = await fetch(`${S.B}/api/sites/${id}/visual/index.html`, { method: 'PUT', headers: { 'Content-Type': 'application/json', referer: `${S.B}/`, cookie: S.jar.a }, body: '{not json' }); assert.ok(nojson.status >= 400 && nojson.status < 500, 'JSON 파싱 실패는 4xx: ' + nojson.status);
assert.equal(fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8').includes('새 제목'), true, '거절된 요청은 파일을 바꾸지 않음');
});
/**
* 머리 + 본문 앞부분만 보내 두고(chunked) 끝내지 않는 요청 → 서버 처리기는 라우트에 들어가 본문 읽기(await)에서 멈춘다. finish() 가 나머지를 보낸다.
* 잠금·동시성 검증용: "먼저 들어왔지만 나중에 본문을 끝낸" 요청이 그 사이의 다른 변경을 덮어쓰지 않는지 본다.
*/
function slowRequest(s, method, urlPath, payload, who = 'a') {
const json = JSON.stringify(payload); let resolve; const done = new Promise((r) => { resolve = r; });
const req = http.request(`${s.B}${urlPath}`, { method, headers: { 'Content-Type': 'application/json', referer: `${s.B}/`, cookie: s.jar[who], 'Transfer-Encoding': 'chunked' } }, (res) => { let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { let b = {}; try { b = JSON.parse(d); } catch (_) {} resolve({ status: res.statusCode, b }); }); });
req.on('error', (e) => resolve({ status: 0, b: { error: e.message } }));
req.write(json.slice(0, 12)); req.flushHeaders();
return { done, finish: () => req.end(json.slice(12)) };
}
/** 뒤처진 저장의 409 검사. 단독 Webmaker 는 파일 hash 불일치(stale + currentHash), Ariweb Suite 의 같은 코드는 잠금 안에서 rev 충돌(conflict)을 먼저 보므로 둘 중 하나면 된다 */
function expectStale(r, hash, label) { assert.equal(r.status, 409, (label || '오래된 저장') + ': ' + JSON.stringify(r.b)); assert.ok(r.b.stale === true || (r.b.conflict && typeof r.b.conflict.currentRev === 'number'), (label || '') + ' stale 또는 rev 충돌: ' + JSON.stringify(r.b)); if (r.b.stale) assert.equal(r.b.currentHash, hash, (label || '') + ' 최신 hash 는 먼저 끝난 저장의 결과'); }
/** 가짜 claude 가 남긴 sleep 자식 정리(서버를 죽여도 남는다) */
function pkillSleep() { try { execFileSync('pkill', ['-f', '^sleep 30$']); } catch (_) {} }
/** 디렉터리 트리의 모드를 바꾼다(실패 주입: 읽기 전용으로 만들어 저장소·git 쓰기를 실패시킨다) */
function chmodTree(dir, mode) { for (const f of fs.readdirSync(dir, { withFileTypes: true })) if (f.isDirectory()) chmodTree(path.join(dir, f.name), mode); fs.chmodSync(dir, mode); }
test('동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다', async () => {
const { id } = await S.mkSite(); await idle(S, id);
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const index = path.join(S.tmp, 'www', id, 'index.html');
const start = (marker) => slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: `
${marker}
`, baseHash: g.b.hash, rev: g.b.rev });
const A = start('첫째-A'); const B = start('둘째-B');
await new Promise((r) => setTimeout(r, 400)); // 두 처리기가 모두 라우트에 들어가 본문을 기다리는 상태를 만든다
A.finish(); const ra = await A.done; assert.equal(ra.status, 200, JSON.stringify(ra.b));
assert.ok(fs.readFileSync(index, 'utf8').includes('첫째-A'), '첫 저장 반영');
B.finish(); const rb = await B.done;
expectStale(rb, ra.b.hash, '둘째는 오래된 저장');
const final = fs.readFileSync(index, 'utf8'); assert.ok(final.includes('첫째-A') && !final.includes('둘째-B'), '첫 저장이 살아 있다: ' + final.slice(0, 200));
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s.b.versions || []).filter((v) => v.kind === 'visual-edit').length, 1, '버전도 한 번만');
});
test('사이트 변경 잠금: 소스 편집기 PUT(baseHash) 과 직접 편집 PUT 이 본문을 보내는 동안 함께 들어와도 잠금 안에서 다시 검사해 뒤의 저장은 409(stale), 파일은 먼저 저장한 쪽 그대로 — 양방향; baseHash 없는 옛 소스 저장은 예전처럼 동작', async () => {
const { id } = await S.mkSite(); await idle(S, id); const index = path.join(S.tmp, 'www', id, 'index.html');
const v0 = ((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions || []).length; // 사이트 생성이 남긴 버전(create)은 이 테스트의 저장이 아니다
// 1) 직접 편집이 먼저 끝남 → 소스 편집기 저장은 오래된 baseHash 로 409
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
let V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '
시각-1
', baseHash: g.b.hash, rev: g.b.rev });
let T = slowRequest(S, 'PUT', `/api/sites/${id}/files/index.html`, { content: PAGE.replace('제목', '소스-1'), baseHash: g.b.hash, rev: g.b.rev });
await new Promise((r) => setTimeout(r, 400));
V.finish(); const rv = await V.done; assert.equal(rv.status, 200, JSON.stringify(rv.b)); assert.ok(fs.readFileSync(index, 'utf8').includes('시각-1'));
T.finish(); const rt = await T.done; expectStale(rt, rv.b.hash, '소스 저장은 오래됨');
let cur = fs.readFileSync(index, 'utf8'); assert.ok(cur.includes('시각-1') && !cur.includes('소스-1'), '직접 편집 저장이 살아 있다');
// 2) 소스 편집기가 먼저 끝남 → 직접 편집 저장은 409 stale
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
T = slowRequest(S, 'PUT', `/api/sites/${id}/files/index.html`, { content: PAGE.replace('제목', '소스-2'), baseHash: g.b.hash, rev: g.b.rev });
V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '
시각-2
', baseHash: g.b.hash, rev: g.b.rev });
await new Promise((r) => setTimeout(r, 400));
T.finish(); const rt2 = await T.done; assert.equal(rt2.status, 200, JSON.stringify(rt2.b)); assert.match(rt2.b.hash || '', /^[a-f0-9]{64}$/, '소스 저장 응답에도 hash');
V.finish(); const rv2 = await V.done; expectStale(rv2, rt2.b.hash, '직접 편집 저장은 오래됨');
cur = fs.readFileSync(index, 'utf8'); assert.ok(cur.includes('소스-2') && !cur.includes('시각-2'));
// 3) baseHash 가 틀린 소스 저장은 바로 409, baseHash 없는 옛 화면의 소스 저장은 200(호환)
const bad = await S.api(`/api/sites/${id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE, baseHash: 'e'.repeat(64) }) }, 'a'); assert.equal(bad.status, 409); assert.equal(bad.b.stale, true);
const old = await S.api(`/api/sites/${id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE.replace('제목', '옛 화면') }) }, 'a'); assert.equal(old.status, 200); assert.ok(fs.readFileSync(index, 'utf8').includes('옛 화면'));
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.deepEqual((s.b.versions || []).slice(v0).map((v) => v.kind), ['visual-edit', 'manual-edit', 'manual-edit'], '이 테스트에서 성공한 저장만 버전으로(409 두 건은 버전 없음)');
});
test('직접 편집 저장 트랜잭션: 사이트 기록(store) 저장이 실패하면 페이지를 원본으로 되돌리고 500 — 버전·manualEdits·임시 파일 없음, 같은 baseHash 로 다시 저장하면 성공', async () => {
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); const sitesDir = path.join(S.tmp, 'sites');
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const before = fs.readFileSync(index, 'utf8'); const s0 = await S.api(`/api/sites/${id}`, {}, 'a'); const refs0 = localRefs(S, id);
fs.chmodSync(sitesDir, 0o555); // 사이트 기록 폴더에 임시 파일을 만들 수 없음 → store.save 실패(로컬 커밋은 이미 만들어진 뒤 → 되감아야 한다)
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '
', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '권한을 되돌리면 같은 요청이 저장된다'); assert.ok(fs.readFileSync(index, 'utf8').includes('다시 저장'));
});
test('AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버)', async () => {
const fake = path.join(os.tmpdir(), `fake-claude-${process.pid}.sh`); fs.writeFileSync(fake, '#!/bin/sh\nsleep 30\n', { mode: 0o755 });
S2 = await boot('busy', { CLAUDE_BIN: fake });
const { id } = await S2.mkSite(); // quick 모드 생성은 바로 AI 작업을 시작한다 → 가짜 claude 가 30초 동안 멈춰 있어 작업이 실행 중
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); }
assert.equal(running, true, '작업 실행 중 상태: ' + S2.out().slice(-400));
const g = await S2.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(g.status, 200, '읽기는 가능');
const put = await S2.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '
x
', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(put.status, 409); assert.match(put.b.error, /AI 작업/);
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
global.__busy = { id, fake };
});
test('배경 AI 작업이 나중에 자기 사이트 객체를 저장해도, 작업 중에 허용되는 짧은 변경(기본 사항 PATCH·모델 등급 PUT·첨부 없는 기록 변경)은 조용히 사라지지 않는다 — 저장소가 읽었을 때 기준으로 바뀐 항목만 디스크의 최신 기록 위에 합친다(작업이 바꾼 commands·status 와 화면이 바꾼 title·tier 가 모두 남는다)', async () => {
const { id } = global.__busy;
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
const before = (await S2.api(`/api/sites/${id}`, {}, 'a')).b; const n0 = (before.commands || []).length;
const cmd = await S2.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '작업 중 변경 경쟁' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b));
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); } assert.equal(running, true, '가짜 claude(sleep 30)가 작업을 붙잡고 있다');
// 작업이 자기 객체를 들고 있는 동안 허용되는 짧은 변경 두 가지(실행 중 검사 없음)
const patch = await S2.api(`/api/sites/${id}`, { method: 'PATCH', body: JSON.stringify({ title: '작업 중 바꾼 제목' }) }, 'a'); assert.equal(patch.status, 200, JSON.stringify(patch.b)); assert.equal(patch.b.title, '작업 중 바꾼 제목');
const tier = (before.tier === 'basic') ? 'standard' : 'basic';
const model = await S2.api(`/api/sites/${id}/model`, { method: 'PUT', body: JSON.stringify({ tier }) }, 'a'); assert.equal(model.status, 200, JSON.stringify(model.b));
const fd = new FormData(); fd.append('files', new Blob(['hello attachment'], { type: 'text/plain' }), 'note-during-job.txt');
const up = await fetch(`${S2.B}/api/sites/${id}/uploads`, { method: 'POST', headers: { referer: `${S2.B}/`, cookie: S2.jar.a }, body: fd }); assert.equal(up.status, 201, '작업 중 첨부 업로드(같은 최상위 항목 attachments): ' + (await up.text()).slice(0, 200));
// 작업을 끝낸다 → 작업 코드가 자기 객체(제목·등급 변경 전)를 저장한다
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
await new Promise((r) => setTimeout(r, 300));
const after = (await S2.api(`/api/sites/${id}`, {}, 'a')).b;
assert.equal(after.running, false);
assert.equal(after.title, '작업 중 바꾼 제목', '작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: ' + JSON.stringify({ title: after.title, status: after.status }));
assert.equal(after.tier, tier, '모델 등급 변경도 남는다');
assert.equal((after.commands || []).length, n0 + 1, '작업 기록(commands)도 남는다'); assert.ok(['error', 'idle', 'cancelled'].includes(after.status) || true);
assert.ok((after.basicsChanged || []).includes('페이지 제목'), '기본 사항 변경 표식도 남는다: ' + JSON.stringify(after.basicsChanged));
assert.ok((after.attachments || []).some((a) => a.name === 'note-during-job.txt'), '작업 중 올린 첨부도 남는다(attachments 는 작업이 시작 때만 바꾸고 저장했으므로 화면의 추가가 산다): ' + JSON.stringify((after.attachments || []).map((a) => a.name)));
});
test('사이트 변경 잠금: 직접 편집 PUT 이 본문을 기다리는 사이에 AI 작업이 시작되면, 본문을 다 받은 뒤 잠금 안에서 다시 검사해 409 로 거절하고 파일을 바꾸지 않는다', async () => {
const { id, fake } = global.__busy; const index = path.join(S2.tmp, 'www', id, 'index.html');
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
assert.equal((await S2.api(`/api/sites/${id}`, {}, 'a')).b.running, false, '중단 뒤 쉬는 상태');
const g = await S2.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(g.status, 200); const before = fs.readFileSync(index, 'utf8');
const V = slowRequest(S2, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '
작업 중 저장
', baseHash: g.b.hash, rev: g.b.rev });
await new Promise((r) => setTimeout(r, 300)); // 처리기가 사전 검사를 지나 본문을 기다리는 상태
const cmd = await S2.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '제목을 바꿔 주세요' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b));
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); }
assert.equal(running, true, 'AI 작업 실행 중');
V.finish(); const rv = await V.done;
assert.equal(rv.status, 409, '본문을 받은 뒤 잠금 안에서 다시 검사: ' + JSON.stringify(rv.b)); assert.match(rv.b.error, /AI 작업/);
assert.equal(fs.readFileSync(index, 'utf8'), before, 'AI 작업 중 파일을 바꾸지 않는다');
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
try { fs.unlinkSync(fake); } catch (_) {}
});
test('읽기 요청은 부작용이 없다: x-ui-lang 헤더를 단 GET/HEAD/OPTIONS 가 여러 번·동시에 와도 사이트 기록 파일(JSON·saveId·Suite rev)이 바뀌지 않고, 본문을 기다리는 직접 편집 저장과 충돌하지 않는다 — 언어는 변경 요청(잠금 안)으로만 저장된다', async () => {
const { id } = await S.mkSite(); await idle(S, id); const record = path.join(S.tmp, 'sites', `${id}.json`);
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '
읽기 중 저장
', baseHash: g.b.hash, rev: g.b.rev }); await new Promise((r) => setTimeout(r, 200));
const before = fs.readFileSync(record, 'utf8');
const reads = []; for (let i = 0; i < 12; i++) reads.push(S.api(`/api/sites/${id}${i % 3 === 1 ? '/files/index.html' : i % 3 === 2 ? '/visual/index.html' : ''}`, { headers: { 'x-ui-lang': i % 2 ? 'en' : 'ja' } }, 'a'));
reads.push(fetch(`${S.B}/api/sites/${id}`, { method: 'HEAD', headers: { referer: `${S.B}/`, cookie: S.jar.a, 'x-ui-lang': 'en' } }).then((r) => ({ status: r.status })));
reads.push(fetch(`${S.B}/api/sites/${id}`, { method: 'OPTIONS', headers: { referer: `${S.B}/`, cookie: S.jar.a, 'x-ui-lang': 'en' } }).then((r) => ({ status: r.status })));
const rs = await Promise.all(reads); for (const r of rs.slice(0, 12)) assert.equal(r.status, 200, JSON.stringify(r.b).slice(0, 120));
assert.equal(fs.readFileSync(record, 'utf8'), before, '읽기 요청은 기록 파일을 바꾸지 않는다(uiLang·saveId·rev 포함)');
V.finish(); const rv = await V.done; assert.equal(rv.status, 200, '읽기들과 겹친 저장은 그대로 성공: ' + JSON.stringify(rv.b).slice(0, 200));
const after = JSON.parse(fs.readFileSync(record, 'utf8')); assert.notEqual(after.uiLang, 'ja', '읽기 헤더가 언어를 저장하지 않았다'); assert.ok((after.manualEdits || []).some((e) => e.kind === 'visual'));
// 변경 요청(잠금 안)의 헤더는 언어를 저장한다(제품 동작 유지)
const patch = await S.api(`/api/sites/${id}`, { method: 'PATCH', headers: { 'x-ui-lang': 'en' }, body: JSON.stringify({ purpose: '언어 저장 확인' }) }, 'a'); assert.equal(patch.status, 200);
assert.equal(JSON.parse(fs.readFileSync(record, 'utf8')).uiLang, 'en', '변경 요청으로만 언어가 저장된다');
});
test('사이트 폐기는 잠금 안에서 다시 읽고 폐기 표식을 원자적으로 잡은 뒤 배포 서버 정리(바깥 await)를 하고, 다시 잠금을 잡아 같은 표식을 확인한 뒤에만 파일·기록을 지운다: 폐기 중(가짜 ssh 2초)에 온 직접 편집 저장·소스 저장·AI 작업 시작·기본 사항 변경·두 번째 폐기는 모두 409; 배포 서버 정리가 실패하면 표식을 풀고 기록(deleteFailed)만 남겨 사이트는 그대로 쓸 수 있다; 성공하면 잡은 사이트만 지워지고 다른 사이트는 그대로다', async () => {
const SSHWRAP = path.join(os.tmpdir(), `wm-ve-sshwrap-${process.pid}`); const FLAGS = path.join(os.tmpdir(), `wm-ve-sshflags-${process.pid}`); fs.mkdirSync(SSHWRAP, { recursive: true }); fs.mkdirSync(FLAGS, { recursive: true });
fs.writeFileSync(path.join(SSHWRAP, 'ssh'), `#!/bin/sh\nsleep 2\nif [ -e "${FLAGS}/SSH_FAIL" ]; then echo "ssh: connect to host fake.test: Connection refused (test)" >&2; exit 255; fi\necho REMOVED\n`, { mode: 0o755 });
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-destroy-'));
fs.writeFileSync(path.join(tmp, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '가짜 배포 서버', type: 'ssh', baseDir: '/srv/sites', publicBaseUrl: '', note: '', manageVhost: false, host: 'fake.test', port: 22, user: 'root', createdAt: new Date().toISOString() }] }));
const S6 = await boot('destroy', { CLAUDE_BIN: FAKE_FAIL, PATH: `${SSHWRAP}:${process.env.PATH}` }, null, tmp);
const markDeployed = (id) => { const f = path.join(S6.tmp, 'sites', `${id}.json`); const s = JSON.parse(fs.readFileSync(f, 'utf8')); s.deployments = [{ at: new Date().toISOString(), targetId: 't1', targetName: '가짜 배포 서버', ok: true, ready: true, phase: 'ready', url: 'http://fake.test/x/', domains: [], domainUrls: [] }]; fs.writeFileSync(f, JSON.stringify(s, null, 2)); };
const siteOf = async (id) => S6.api(`/api/sites/${id}`, {}, 'a');
try {
// 1) 폐기 중의 변경 요청은 모두 409, 폐기는 성공, 다른 사이트는 그대로
const A = await S6.mkSite(); await idle(S6, A.id); markDeployed(A.id); const B = await S6.mkSite(); await idle(S6, B.id);
const titleA = (await siteOf(A.id)).b.title; const g = await S6.api(`/api/sites/${A.id}/visual/index.html`, {}, 'a');
const D = S6.api(`/api/sites/${A.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleA }) }, 'a'); await new Promise((r) => setTimeout(r, 400)); // 가짜 ssh 가 2초 멈춰 있는 동안
const during = await Promise.all([
S6.api(`/api/sites/${A.id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '
', baseHash: g2.b.hash, rev: g2.b.rev }) }, 'a'); assert.equal(ok2.status, 200, '표식이 풀려 저장된다: ' + JSON.stringify(ok2.b));
const cmd = await S6.api(`/api/sites/${C.id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '실패 뒤 작업' }) }, 'a'); assert.equal(cmd.status, 202, '표식이 풀려 작업도 시작된다: ' + JSON.stringify(cmd.b)); await idle(S6, C.id);
} finally { try { S6.child.kill('SIGTERM'); } catch (_) {} fs.rmSync(SSHWRAP, { recursive: true, force: true }); fs.rmSync(FLAGS, { recursive: true, force: true }); }
});
test('사이트 변경 잠금(전체 감사): 본문을 기다리는 사이에 직접 편집 저장이 끝난 다국어 설정 저장·버전 복원·AI 작업 시작은 잠금 안에서 사이트 기록을 다시 읽으므로 그 저장의 버전·manualEdits 를 덮어쓰지 않는다', async () => {
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); const PAD = 'x'.repeat(64);
const siteOf = async () => (await S.api(`/api/sites/${id}`, {}, 'a')).b; const hasCommit = (s, c) => (s.git.changes || []).some((x) => x.commit === c);
// (a) 다국어·현지 설정 저장(PUT /langs/settings) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 설정 저장이 그 버전·manualEdits 를 지우면 안 된다
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const L = slowRequest(S, 'PUT', `/api/sites/${id}/langs/settings`, { timeZone: 'Asia/Seoul', pad: PAD }); await new Promise((r) => setTimeout(r, 300));
const v1 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '
경쟁-설정
', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v1.status, 200, JSON.stringify(v1.b));
L.finish(); const rl = await L.done; assert.equal(rl.status, 200, JSON.stringify(rl.b));
let s = await siteOf(); assert.ok(hasCommit(s, v1.b.change.commit), '설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); assert.equal((s.locale || {}).timeZone, 'Asia/Seoul', '설정도 저장됨'); assert.ok((s.manualEdits || []).some((e) => e.kind === 'visual'), 'manualEdits 유지');
// (b) 버전 복원(POST /versions//restore) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 복원은 잠금 안에서 다시 읽은 기록 위에 새 복원 버전을 더한다(먼저 끝난 버전이 목록에서 사라지지 않는다)
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const R = slowRequest(S, 'POST', `/api/sites/${id}/versions/${v1.b.change.no}/restore`, { pad: PAD }); await new Promise((r) => setTimeout(r, 300));
const v2 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '
경쟁-복원
', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v2.status, 200, JSON.stringify(v2.b));
R.finish(); const rrs = await R.done; assert.equal(rrs.status, 200, JSON.stringify(rrs.b).slice(0, 300));
s = await siteOf(); assert.ok(hasCommit(s, v2.b.change.commit), '복원이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind)));
const last = s.git.changes[s.git.changes.length - 1]; assert.equal(last.kind, 'restore'); assert.ok(last.no > v2.b.change.no, '복원 버전은 먼저 끝난 저장 뒤 번호'); assert.ok(fs.readFileSync(index, 'utf8').includes('경쟁-설정'), '복원 내용은 #' + v1.b.change.no + ' 의 페이지');
// (c) AI 작업 시작(POST /commands) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 작업은 잠금 안에서 다시 읽은 기록으로 시작·종료 저장을 하므로 그 버전이 남는다
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const C = slowRequest(S, 'POST', `/api/sites/${id}/commands`, { instruction: '제목을 바꿔 주세요', pad: PAD }); await new Promise((r) => setTimeout(r, 300));
const v3 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '
경쟁-작업
', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v3.status, 200, JSON.stringify(v3.b));
C.finish(); const rc = await C.done; assert.equal(rc.status, 202, JSON.stringify(rc.b)); await idle(S, id);
s = await siteOf(); assert.ok(hasCommit(s, v3.b.change.commit), '작업의 종료 저장이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); assert.ok((s.commands || []).length >= 1, '작업 기록도 남음');
});
test('화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다', async () => {
const page = await fetch(`${S.B}/`, { headers: { referer: `${S.B}/`, cookie: S.jar.a }, redirect: 'manual' }); assert.equal(page.status, 200); const html = await page.text();
assert.match(html, /