Files
AriWeb-Suite/modules/webmaker/test/visual-editor-server.test.js
T

606 lines
91 KiB
JavaScript
Raw Normal View History

'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 = '<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><title>직접 편집</title><style>h1{color:navy}</style><script>window.ran = (window.ran||0)+1</script></head>\n<body><h1 id="h">제목</h1><p class="lead" onclick="track()">문단</p><a href="about.html">소개</a><img src="images/pic.png" alt="사진"><script src="/wm-stats.js"></script></body></html>\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: '<p>x</p>', baseHash: 'x' }) }, 'b')).status, 404);
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'nobody')).status, 401, '로그인 없음');
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', referer: null, headers: { 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }, body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 401, 'Referer·CSRF 없는 fetch');
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', referer: `${S.B}/${id}/index.html`, body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 401, '생성 사이트 페이지 출처');
});
test('허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절', async () => {
const { id } = global.__ve;
const css = await S.api(`/api/sites/${id}/visual/style.css`, {}, 'a'); assert.equal(css.status, 400); assert.match(css.b.error, /HTML/);
assert.equal((await S.api(`/api/sites/${id}/visual/style.css`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 400);
assert.equal((await S.api(`/api/sites/${id}/visual/..%2F..%2Fetc%2Fpasswd.html`, {}, 'a')).status, 400, '경로 탈출');
assert.equal((await S.api(`/api/sites/${id}/visual/nope.html`, {}, 'a')).status, 404, '없는 파일');
fs.symlinkSync('/etc/hostname', path.join(S.tmp, 'www', id, 'link.html')); assert.equal((await S.api(`/api/sites/${id}/visual/link.html`, {}, 'a')).status, 400, '링크 파일');
});
test('캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다', async () => {
const { id } = global.__ve;
const r = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(r.status, 200);
assert.equal(r.b.path, 'index.html'); assert.match(r.b.hash, /^[a-f0-9]{64}$/); assert.match(r.b.session, /^[a-f0-9]{32}$/); assert.equal(typeof r.b.canvas, 'string');
const c = r.b.canvas;
assert.ok(!/<script>window\.ran/.test(c) && c.includes('<script type="text/x-wm-ve-disabled">window.ran'), '머리 스크립트 비활성화');
assert.ok(c.includes('type="text/x-wm-ve-disabled" src="/wm-stats.js"') || /<script type="text\/x-wm-ve-disabled"[^>]*src="\/wm-stats\.js"/.test(c), '외부 스크립트 비활성화');
assert.ok(!/\sonclick=/.test(c) && c.includes('data-wm-ve-on-click="track()"'));
assert.ok(c.includes(`<base href="${S.B}/${id}/"`), '상대 경로 자원이 사이트 주소 기준으로 풀린다');
assert.match(c, /Content-Security-Policy" content="[^"]*script-src 'nonce-/); assert.ok(c.includes('WMVisualCore') && c.includes(`"session":"${r.b.session}"`) && c.includes(`"parentOrigin":"${S.B}"`), '런타임·세션·부모 출처');
assert.ok(!c.includes('wh_session') && !c.includes(SETUP_TOKEN), '세션 쿠키·토큰 같은 비밀값 없음');
const f = await S.api(`/api/sites/${id}/files/index.html`, {}, 'a'); assert.equal(f.status, 200); assert.equal(f.b.hash, r.b.hash, 'GET /files 의 hash = 캔버스 hash');
assert.equal(r.headers.get('cache-control'), 'no-store');
});
test('저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존', async () => {
const { id } = global.__ve;
const before = await S.api(`/api/sites/${id}`, {}, 'a'); const credit0 = before.b.credit.balance; const cmds0 = (before.b.commands || []).length; const ver0 = (before.b.versions || []).length;
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const body = '<h1 id="h" contenteditable="true" data-wm-ve-editable="">새 제목</h1><div data-wm-ve-ui=""><button>툴바</button></div><p class="lead" data-wm-ve-on-click="track()">문단</p><section class="wm-section"><h2>새 섹션</h2></section><a href="about.html">소개</a><img src="images/pic.png" alt="사진"><script type="text/x-wm-ve-disabled" src="/wm-stats.js"></script>';
const put = 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(put.status, 200, JSON.stringify(put.b)); assert.equal(put.b.path, 'index.html'); assert.match(put.b.hash, /^[a-f0-9]{64}$/); assert.notEqual(put.b.hash, g.b.hash); assert.ok(Array.isArray(put.b.files) && put.b.files.includes('index.html'));
const saved = fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8');
assert.ok(saved.startsWith('<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><title>직접 편집</title><style>h1{color:navy}</style><script>window.ran = (window.ran||0)+1</script></head>\n<body>'), '머리·스크립트 보존: ' + saved.slice(0, 120));
assert.ok(saved.includes('<h1 id="h">새 제목</h1><p class="lead" onclick="track()">문단</p><section class="wm-section"><h2>새 섹션</h2></section>'), saved);
assert.ok(saved.includes('<script src="/wm-stats.js"></script></body></html>'), '본문 끝 스크립트 복원'); assert.ok(!/data-wm-ve|contenteditable|툴바/.test(saved), '표식·덧씌운 요소 없음');
const after = await S.api(`/api/sites/${id}`, {}, 'a');
assert.equal(after.b.credit.balance, credit0, '캐시 미차감'); assert.equal((after.b.commands || []).length, cmds0, 'AI 명령 기록에 추가되지 않음');
assert.equal((after.b.versions || []).length, ver0 + 1, '버전 1개 추가'); const last = after.b.versions[after.b.versions.length - 1]; assert.equal(last.kind, 'visual-edit'); assert.match(last.message, /직접 편집.*index\.html/); assert.ok(last.commit);
assert.ok((after.b.manualEdits || []).some((m) => m.path === 'index.html' && m.kind === 'visual'), '직접 편집 기록');
assert.ok(put.b.change && put.b.change.kind === 'visual-edit' && put.b.git && put.b.versions, '응답에 버전 정보');
assert.equal((await S.api(`/api/sites/${id}/files/index.html`, {}, 'a')).b.hash, put.b.hash, '소스 편집기용 hash 와 같다');
global.__ve.hash = put.b.hash;
});
test('오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400', async () => {
const { id, hash } = global.__ve;
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const stale = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>다른 창</p>', baseHash: 'd'.repeat(64), rev: g.b.rev }) }, 'a');
assert.equal(stale.status, 409); assert.match(stale.b.error, /먼저 저장|달라졌습니다/); assert.equal(stale.b.currentHash, hash);
assert.equal(fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8').includes('다른 창'), false, '파일은 그대로');
const nohash = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>' }) }, 'a'); assert.equal(nohash.status, 400); assert.match(nohash.b.error, /baseHash/);
const bad = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: 5, baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(bad.status, 400); assert.match(bad.b.error, /문자열/);
const js = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p><script>alert(1)</script>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(js.status, 400); assert.match(js.b.error, /스크립트/);
const ev = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p onload="x()">x</p>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ev.status, 400); assert.match(ev.b.error, /이벤트 속성/);
const url = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<a href="javascript:alert(1)">x</a>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(url.status, 400); assert.match(url.b.error, /허용되지 않는/);
const big = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>' + 'x'.repeat(5 * 1024 * 1024 + 100) + '</p>', 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: `<h1 id="h">${marker}</h1>`, 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: '<h1 id="h">시각-1</h1>', 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: '<h1 id="h">시각-2</h1>', 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: '<h1 id="h">기록 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(sitesDir, 0o755); }
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /저장하지 못했습니다|되돌렸습니다/); assert.match(r.b.error, /그대로|되돌/); assert.equal(r.b.rolledBack, true, '검증된 되돌리기: ' + JSON.stringify(r.b));
assert.equal(fs.readFileSync(index, 'utf8'), before, '페이지는 원본으로 되돌아간다'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)), '임시 파일 없음');
assert.deepEqual(localRefs(S, id), refs0, '로컬 커밋·변경 브랜치를 되감아 브랜치가 저장 전 그대로'); let indexClean = true; try { siteGit(S, id, ['diff', '--cached', '--quiet']); } catch (_) { indexClean = false; } assert.equal(indexClean, true, 'add 로 올라간 인덱스를 비운다');
const s1 = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s1.b.versions || []).length, (s0.b.versions || []).length, '버전 추가 없음'); assert.equal((s1.b.manualEdits || []).length, (s0.b.manualEdits || []).length, 'manualEdits 추가 없음');
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">다시 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '같은 baseHash 로 재시도 성공: ' + JSON.stringify(ok.b)); assert.ok(fs.readFileSync(index, 'utf8').includes('다시 저장'));
assert.equal((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions.filter((v) => v.kind === 'visual-edit').length, 1);
});
test('직접 편집 저장 트랜잭션: Git 버전 기록이 실패하면 200 으로 꾸미지 않고 페이지·사이트 기록을 저장 전으로 되돌려 500 — 거짓 visual-edit 버전 없음, 같은 baseHash 로 다시 저장하면 성공', async () => {
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html');
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const first = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">첫 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(first.status, 200, JSON.stringify(first.b)); assert.ok(first.b.change && first.b.change.commit, '첫 저장은 git 기록');
const gitDir = path.join(S.tmp, 'git', id); assert.ok(fs.existsSync(path.join(gitDir, 'HEAD')), '사이트 git 저장소'); const before = fs.readFileSync(index, 'utf8'); const s0 = await S.api(`/api/sites/${id}`, {}, 'a');
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
chmodTree(gitDir, 0o555); // git 이 index.lock·객체를 쓸 수 없음 → 커밋 실패
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">git 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { chmodTree(gitDir, 0o755); }
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /버전|Git|git/); assert.match(r.b.error, /되돌/); assert.equal(r.b.rolledBack, true, '검증된 되돌리기: ' + JSON.stringify(r.b));
assert.equal(fs.readFileSync(index, 'utf8'), before, '페이지는 저장 전으로'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)));
assert.equal(siteGit(S, id, ['status', '--porcelain']), '', '인덱스·작업 트리에 남은 변경 없음'); assert.equal(siteGit(S, id, ['rev-parse', 'HEAD']), first.b.change.commit, 'HEAD 는 첫 저장 커밋 그대로'); assert.ok(!Object.keys(localRefs(S, id)).some((r2) => /visual-edit$/.test(r2) && !r2.endsWith(first.b.change.branch)), '실패한 저장의 변경 브랜치 없음: ' + JSON.stringify(localRefs(S, id)));
const s1 = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s1.b.versions || []).length, (s0.b.versions || []).length, '거짓 버전 없음'); assert.equal((s1.b.manualEdits || []).length, (s0.b.manualEdits || []).length, 'manualEdits 되돌림'); assert.equal(s1.b.versions.filter((v) => v.kind === 'visual-edit').length, 1);
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">다시 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '권한을 되돌리면 같은 baseHash 로 저장: ' + JSON.stringify(ok.b)); assert.ok(ok.b.change && ok.b.change.kind === 'visual-edit');
assert.equal((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions.filter((v) => v.kind === 'visual-edit').length, 2);
});
/** 사이트 git 저장소에 명령을 보낸다(검증용 읽기·훅 설치) */
function siteGit(s, id, args) { return execFileSync('git', args, { env: { ...process.env, GIT_DIR: path.join(s.tmp, 'git', id), GIT_WORK_TREE: path.join(s.tmp, 'www', id) }, encoding: 'utf8' }).trim(); }
/** 원격(bare) 저장소의 모든 ref → { refname: sha } */
function remoteRefs() { const out = execFileSync('git', ['--git-dir', BARE, 'for-each-ref', '--format=%(refname) %(objectname)'], { encoding: 'utf8' }).trim(); return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => l.split(' '))); }
/** 로컬 사이트 저장소의 모든 브랜치 → { refname: sha } */
function localRefs(s, id) { const out = siteGit(s, id, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/']); return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => l.split(' '))); }
/** 되돌리기 검증: 페이지·HEAD·인덱스(HEAD 와 같음)·작업 트리(변경 없음)·로컬 브랜치·원격 ref·버전 목록·manualEdits 가 모두 저장 전 그대로 */
function assertRolledBack(s, id, snap, r, label) {
assert.equal(r.status, 500, label + ': ' + JSON.stringify(r.b)); assert.equal(r.b.rolledBack, true, label + ': rolledBack 은 검증된 뒤에만 true: ' + JSON.stringify(r.b)); assert.match(r.b.error, /되돌렸습니다/, label);
assert.equal(fs.readFileSync(snap.index, 'utf8'), snap.page, label + ': 페이지 원본'); assert.ok(!fs.readdirSync(path.dirname(snap.index)).some((f) => /\.tmp$/.test(f)), label + ': 임시 파일 없음');
assert.equal(siteGit(s, id, ['rev-parse', '--verify', '-q', 'HEAD']), snap.head, label + ': HEAD 그대로');
let indexClean = true; try { siteGit(s, id, ['diff', '--cached', '--quiet']); } catch (_) { indexClean = false; } assert.equal(indexClean, true, label + ': 인덱스가 HEAD 와 같다(add 로 올라간 것 없음)');
assert.equal(siteGit(s, id, ['status', '--porcelain']), '', label + ': 작업 트리 변경 없음');
assert.deepEqual(localRefs(s, id), snap.local, label + ': 로컬 브랜치(변경 브랜치 없음)'); assert.deepEqual(remoteRefs(), snap.remote, label + ': 원격 ref 그대로');
}
test('직접 편집 저장 트랜잭션(원격 Git, 진짜 bare 저장소): 성공하면 페이지·사이트 기록·로컬 커밋 뒤 main 과 변경 브랜치가 한 번의 원자적 푸시로 함께 바뀌고, 푸시 거부·커밋 실패(add 뒤)·사이트 기록 실패 때는 원격 두 ref 모두 그대로이며 로컬 HEAD·인덱스·작업 트리·변경 브랜치·페이지·기록이 검증된 채 되돌아가 500(rolledBack) — 같은 baseHash 로 재시도하면 성공', async () => {
execFileSync('git', ['init', '-q', '--bare', BARE]);
fs.writeFileSync(path.join(BARE, 'hooks', 'pre-receive'), `#!/bin/sh\nif [ -e "${BARE}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
// git 래퍼(서버의 PATH 앞): 표식 파일로 "원격이 받았는데 클라이언트가 오류"(FAIL_AFTER)·"브랜치만 올라가고 실패"(MIXED)·"받은 뒤 오류 + 그 뒤 ls-remote 불통"(FLAKY)·"완전 불통"(DOWN) 을 결정적으로 만든다
fs.mkdirSync(GITWRAP, { recursive: true });
fs.writeFileSync(path.join(GITWRAP, 'git'), `#!/bin/sh\nF="${BARE}"\nif [ -e "$F/SLOW_COMMIT" ]; then for a in "$@"; do if [ "$a" = "commit" ]; then sleep 2; break; fi; done; fi\nif [ "$1" = "push" ]; then\n if [ -e "$F/DOWN" ]; then echo "fatal: unable to access origin: connection reset (test)" >&2; exit 128; fi\n if [ -e "$F/FAIL_AFTER" ] || [ -e "$F/FLAKY" ]; then "${REAL_GIT}" "$@"; touch "$F/PUSHED_ONCE"; echo "fatal: the remote end hung up unexpectedly (test)" >&2; exit 128; fi\n if [ -e "$F/MIXED" ]; then SPEC=""; for a in "$@"; do case "$a" in *changes/*) SPEC="$a";; esac; done; "${REAL_GIT}" push -q origin "$SPEC"; echo "fatal: partial push (test)" >&2; exit 128; fi\nfi\nif [ "$1" = "ls-remote" ]; then\n if [ -e "$F/DOWN" ]; then echo "fatal: unable to access origin: connection reset (test)" >&2; exit 128; fi\n if [ -e "$F/FLAKY" ] && [ -e "$F/PUSHED_ONCE" ]; then echo "fatal: unable to access origin: timeout (test)" >&2; exit 128; fi\nfi\nexec "${REAL_GIT}" "$@"\n`, { mode: 0o755 });
S3 = await boot('remote', { CLAUDE_BIN: FAKE_FAIL, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea', PATH: `${GITWRAP}:${process.env.PATH}` });
const { id, dir } = await S3.mkSite(); await idle(S3, id); const index = path.join(dir, 'index.html'); const gitDir = path.join(S3.tmp, 'git', id);
const MAIN = `refs/heads/sites/${id}/main`; const branchRef = (rec) => `refs/heads/${rec.branch}`;
// 훅: 푸시가 원격에 닿은 **순간** 의 사이트 기록(푸시 전에 저장된 것)을 복사해 둔다 — 그 기록이 "원격 반영됨" 을 주장하면 안 된다
const record = path.join(S3.tmp, 'sites', `${id}.json`); const captured = path.join(BARE, 'during-push.json');
fs.writeFileSync(path.join(BARE, 'hooks', 'pre-receive'), `#!/bin/sh\ncp "${record}" "${captured}" 2>/dev/null\necho push >> "${BARE}/pushes.log"\nif [ -e "${BARE}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
const lastVisual = (site) => (site.git.changes || []).filter((c) => c.kind === 'visual-edit').slice(-1)[0];
const waitVerified = async (commit) => { for (let i = 0; i < 60; i++) { const s = (await S3.api(`/api/sites/${id}`, {}, 'a')).b; const c = (s.git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true && c.remote === 'verified') return c; await new Promise((r) => setTimeout(r, 100)); } throw new Error('원격 확인(verified) 이 되지 않음'); };
// 0) 성공: 푸시 전에 저장된 기록은 pushed:false·remote:'pending'(원격 반영을 주장하지 않는다), 응답도 같다; 원격 main·변경 브랜치가 같은 커밋을 가리키고, 조정(reconcile)이 ls-remote 로 확인한 뒤에야 pushed:true·remote:'verified' 가 된다
const n0 = Object.keys(remoteRefs()).length;
let g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
const ok0 = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">원격 첫 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(ok0.status, 200, JSON.stringify(ok0.b)); assert.ok(ok0.b.change && ok0.b.change.commit, JSON.stringify(ok0.b.change));
assert.ok(fs.existsSync(captured), '훅이 푸시 순간의 기록을 복사했다'); const during = lastVisual(JSON.parse(fs.readFileSync(captured, 'utf8')));
assert.ok(during && during.commit === ok0.b.change.commit, '푸시 전에 로컬 커밋·기록이 저장되어 있다: ' + JSON.stringify(during));
assert.equal(during.pushed, false, '푸시 전 기록은 원격 반영을 주장하지 않는다: ' + JSON.stringify(during)); assert.equal(during.remote, 'pending', '푸시 전 기록은 pending: ' + JSON.stringify(during));
assert.equal(ok0.b.change.pushed, false, '응답도 원격 ref 로 확인하기 전에는 pushed 를 주장하지 않는다: ' + JSON.stringify(ok0.b.change)); assert.equal(ok0.b.change.remote, 'pending');
let rr = remoteRefs(); assert.equal(rr[MAIN], ok0.b.change.commit, '원격 main'); assert.equal(rr[branchRef(ok0.b.change)], ok0.b.change.commit, '원격 변경 브랜치'); assert.equal(Object.keys(rr).length, Math.max(n0, 1) + 1, '새 변경 브랜치 하나(+ 처음이면 main): ' + JSON.stringify(rr));
const v0 = await waitVerified(ok0.b.change.commit); assert.equal(v0.error, null); assert.ok(v0.verifiedAt, '확인 시각');
const pushes0 = fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length; assert.equal(pushes0, 1, '저장 뒤 조정은 ls-remote 로 확인만 하고 다시 푸시하지 않는다(기록 훅 설치 뒤 푸시 1회): ' + pushes0);
assert.equal(siteGit(S3, id, ['status', '--porcelain']), '', '성공 뒤 작업 트리 깨끗');
const snapshot = async () => ({ index, page: fs.readFileSync(index, 'utf8'), head: siteGit(S3, id, ['rev-parse', '--verify', '-q', 'HEAD']), local: localRefs(S3, id), remote: remoteRefs(), site: (await S3.api(`/api/sites/${id}`, {}, 'a')).b });
const assertSiteSame = (snap, after, label) => { assert.equal((after.versions || []).length, (snap.site.versions || []).length, label + ': 거짓 버전 없음'); assert.equal((after.manualEdits || []).length, (snap.site.manualEdits || []).length, label + ': manualEdits 되돌림'); assert.equal((after.git && after.git.seq) || 0, (snap.site.git && snap.site.git.seq) || 0, label + ': 변경 번호 그대로'); };
// 1) 원격이 푸시를 거부(pre-receive) → 500, 원격 두 ref 모두 그대로, 로컬 커밋·변경 브랜치·인덱스·페이지·기록 되돌림
fs.writeFileSync(path.join(BARE, 'REJECT'), '');
g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); let snap = await snapshot();
let r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">푸시 거부</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assertRolledBack(S3, id, snap, r, '푸시 거부'); assert.match(r.b.error, /원격|푸시|Git/); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '푸시 거부');
fs.unlinkSync(path.join(BARE, 'REJECT'));
let retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">푸시 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(retry.status, 200, '같은 baseHash 로 재시도: ' + JSON.stringify(retry.b)); assert.equal(retry.b.change.remote, 'pending'); await waitVerified(retry.b.change.commit); rr = remoteRefs(); assert.equal(rr[MAIN], retry.b.change.commit); assert.equal(rr[branchRef(retry.b.change)], retry.b.change.commit); assert.equal(Object.keys(rr).length, Math.max(n0, 1) + 2, '변경 브랜치는 성공한 저장마다 하나(실패한 저장의 브랜치 없음): ' + JSON.stringify(rr));
assert.equal(retry.b.change.no, ok0.b.change.no + 1, '실패한 저장은 변경 번호를 쓰지 않는다');
// 2) add 는 되는데 커밋이 실패(pre-commit 훅) → 인덱스도 비워야 한다, 원격·HEAD·브랜치 그대로
fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true }); fs.writeFileSync(path.join(gitDir, 'hooks', 'pre-commit'), '#!/bin/sh\necho "commit rejected by test hook" >&2\nexit 1\n', { mode: 0o755 });
g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); snap = await snapshot();
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">커밋 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
fs.unlinkSync(path.join(gitDir, 'hooks', 'pre-commit'));
assertRolledBack(S3, id, snap, r, '커밋 실패'); assert.match(r.b.error, /버전|Git|git/); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '커밋 실패');
retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">커밋 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); assert.equal(remoteRefs()[MAIN], retry.b.change.commit);
// 3) 로컬 커밋은 되는데 사이트 기록(store) 저장이 실패 → 원격에 아무것도 올리지 않고 로컬 커밋·변경 브랜치를 되감는다
const sitesDir = path.join(S3.tmp, 'sites'); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); snap = await snapshot();
fs.chmodSync(sitesDir, 0o555);
try { r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">기록 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(sitesDir, 0o755); }
assertRolledBack(S3, id, snap, r, '기록 실패'); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '기록 실패');
retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">기록 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); rr = remoteRefs(); assert.equal(rr[MAIN], retry.b.change.commit); assert.equal(rr[branchRef(retry.b.change)], retry.b.change.commit);
assert.ok(fs.readFileSync(index, 'utf8').includes('기록 재시도'));
await waitVerified(retry.b.change.commit);
const final = (await S3.api(`/api/sites/${id}`, {}, 'a')).b; assert.deepEqual(final.git.changes.filter((c) => c.kind === 'visual-edit').map((c) => [c.pushed, c.remote, c.error]), [[true, 'verified', null], [true, 'verified', null], [true, 'verified', null], [true, 'verified', null]], '성공한 저장 4건만 기록되고 모두 원격 ref 로 확인됨: ' + JSON.stringify(final.git.changes.map((c) => [c.no, c.kind, c.pushed, c.remote])));
global.__remote = { id, record, commit: retry.b.change.commit, branch: retry.b.change.branch };
});
test('원자적 푸시의 모호한 클라이언트 오류: 원격이 받은 뒤 오류면 main·변경 브랜치를 ls-remote 로 확인해 성공으로(되돌리지 않음·pending → verified), 브랜치만 올라간 혼합 상태·확인 불가(불통)면 로컬 커밋·페이지·기록을 pending 으로 남기고 remoteState:ambiguous 를 돌려주며 rolledBack 을 주장하지 않는다(뒤에 조정이 해결), 아예 닿지 않으면(푸시 전 확인 실패) 원격이 바뀌지 않았음을 알고 되돌린다', async () => {
const { id, record } = global.__remote; const index = path.join(S3.tmp, 'www', id, 'index.html'); const MAIN = `refs/heads/sites/${id}/main`;
const flags = ['DOWN', 'FAIL_AFTER', 'FLAKY', 'MIXED', 'PUSHED_ONCE']; const clear = () => { for (const f of flags) try { fs.unlinkSync(path.join(BARE, f)); } catch (_) {} };
const site = async () => (await S3.api(`/api/sites/${id}`, {}, 'a')).b; const pushes = () => fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length;
const waitVerified = async (commit) => { for (let i = 0; i < 60; i++) { const c = ((await site()).git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true && c.remote === 'verified') return c; await new Promise((r) => setTimeout(r, 100)); } throw new Error('verified 되지 않음'); };
const settle = () => new Promise((r) => setTimeout(r, 400)); // 응답 뒤 setImmediate 조정이 끝날 시간
try {
// 1) 원격이 받았는데 클라이언트가 오류: 두 ref 가 의도한 커밋 → 성공으로 본다(되돌리지 않는다), 기록은 pending → 조정 뒤 verified
fs.writeFileSync(path.join(BARE, 'FAIL_AFTER'), ''); let g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); let before = fs.readFileSync(index, 'utf8');
let r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">받은 뒤 오류</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(r.status, 200, '원격이 받았으면 성공: ' + JSON.stringify(r.b)); assert.equal(r.b.rolledBack, undefined); assert.equal(r.b.change.remote, 'pending'); assert.ok(fs.readFileSync(index, 'utf8').includes('받은 뒤 오류'));
let rr = remoteRefs(); assert.equal(rr[MAIN], r.b.change.commit); assert.equal(rr[`refs/heads/${r.b.change.branch}`], r.b.change.commit);
clear(); await waitVerified(r.b.change.commit);
// 2) 브랜치만 올라가고 main 은 아님(혼합): 되돌리지 않고 pending + remoteState:ambiguous, 페이지·로컬 커밋·기록 유지; 조정(다시 푸시)으로 해결
fs.writeFileSync(path.join(BARE, 'MIXED'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const mainBefore = remoteRefs()[MAIN];
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">혼합 상태</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(r.status, 200, '로컬 저장은 성공·원격은 모호: ' + JSON.stringify(r.b)); assert.equal(r.b.remoteState, 'ambiguous', JSON.stringify(r.b)); assert.notEqual(r.b.rolledBack, true); assert.equal(r.b.change.remote, 'pending'); assert.equal(r.b.change.pushed, false); assert.match(r.b.change.error || '', /모호|혼합|확인/);
assert.ok(fs.readFileSync(index, 'utf8').includes('혼합 상태'), '페이지 유지'); assert.equal(siteGit(S3, id, ['rev-parse', 'HEAD']), r.b.change.commit, '로컬 커밋 유지');
rr = remoteRefs(); assert.equal(rr[`refs/heads/${r.b.change.branch}`], r.b.change.commit, '브랜치는 올라감'); assert.equal(rr[MAIN], mainBefore, 'main 은 그대로');
await settle(); let c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === false && c.remote === 'pending', '읽기 결과도 pending(거짓 성공 없음): ' + JSON.stringify(c));
clear(); const retry = await S3.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b));
c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === true && c.remote === 'verified', '조정 뒤 verified: ' + JSON.stringify(c)); assert.equal(remoteRefs()[MAIN], r.b.change.commit, 'main 도 따라옴');
// 3) 받은 뒤 오류 + 그 뒤 원격 확인 불가(불통): 확인할 수 없으니 pending + ambiguous, 되돌리지 않음; 불통이 풀리면 확인만으로 verified(추가 푸시 없음)
fs.writeFileSync(path.join(BARE, 'FLAKY'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">확인 불가</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(r.status, 200, JSON.stringify(r.b)); assert.equal(r.b.remoteState, 'ambiguous', JSON.stringify(r.b)); assert.notEqual(r.b.rolledBack, true); assert.equal(r.b.change.pushed, false);
rr = remoteRefs(); assert.equal(rr[MAIN], r.b.change.commit, '실제로는 원격이 받았다'); await settle(); const n = pushes();
clear(); const retry2 = await S3.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry2.status, 200);
c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === true && c.remote === 'verified', JSON.stringify(c)); assert.equal(pushes(), n, '이미 올라가 있으니 확인만(추가 푸시 없음)');
// 4) 완전 불통(푸시 전 원격 확인부터 실패): 푸시를 시도하지 않았으므로 원격이 바뀌지 않았음을 알고 되돌린다 — 500 rolledBack:true
fs.writeFileSync(path.join(BARE, 'DOWN'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); before = fs.readFileSync(index, 'utf8'); const refs0 = remoteRefs(); const head0 = siteGit(S3, id, ['rev-parse', 'HEAD']); const s0 = await site();
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">불통</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.equal(r.b.rolledBack, true, JSON.stringify(r.b)); assert.equal(fs.readFileSync(index, 'utf8'), before); assert.deepEqual(remoteRefs(), refs0); assert.equal(siteGit(S3, id, ['rev-parse', 'HEAD']), head0); assert.equal((await site()).git.changes.length, s0.git.changes.length);
clear(); const ok = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">불통 뒤 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, JSON.stringify(ok.b)); await waitVerified(ok.b.change.commit);
global.__remote = { ...global.__remote, commit: ok.b.change.commit, branch: ok.b.change.branch };
void record;
} finally { clear(); }
});
test('원격 반영 조정(기동 때): 기록 저장과 푸시 사이에 프로세스가 죽은 상태(기록은 pending, 로컬 커밋·변경 브랜치는 있고 원격에는 없음)를 만들어 서버를 같은 데이터로 다시 띄우면, 기동 조정이 로컬 커밋·브랜치를 찾아 main + 변경 브랜치를 원자적으로 올리고 원격 ref 로 확인한 뒤에야 pushed:true·verified 로 바꾼다', async () => {
const { id, record, commit, branch } = global.__remote;
S3.child.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 300));
// 크래시 창 재현: 원격의 변경 브랜치를 지우고 main 을 한 커밋 전으로 되돌린 뒤, 기록을 푸시 전 상태(pending)로 되돌린다
const site = JSON.parse(fs.readFileSync(record, 'utf8')); const rec = site.git.changes.find((c) => c.commit === commit); assert.ok(rec);
rec.pushed = false; rec.remote = 'pending'; rec.error = null; delete rec.verifiedAt; fs.writeFileSync(record, JSON.stringify(site, null, 2));
execFileSync('git', ['--git-dir', BARE, 'update-ref', '-d', `refs/heads/${branch}`]); execFileSync('git', ['--git-dir', BARE, 'update-ref', `refs/heads/sites/${id}/main`, `${commit}~1`]);
const before = remoteRefs(); assert.equal(before[`refs/heads/${branch}`], undefined); assert.notEqual(before[`refs/heads/sites/${id}/main`], commit);
const pushesBefore = fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length;
S4 = await boot('restart', { CLAUDE_BIN: FAKE_FAIL, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea' }, S3.tmp);
let c = null; for (let i = 0; i < 80; i++) { const s = (await S4.api(`/api/sites/${id}`, {}, 'a')).b; c = (s.git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true) break; await new Promise((r) => setTimeout(r, 100)); }
assert.ok(c && c.pushed === true && c.remote === 'verified' && c.error === null, '기동 조정 뒤 원격 ref 로 확인된 기록: ' + JSON.stringify(c));
const after = remoteRefs(); assert.equal(after[`refs/heads/${branch}`], commit, '변경 브랜치가 원격에 올라갔다: ' + JSON.stringify(after)); assert.equal(after[`refs/heads/sites/${id}/main`], commit, 'main 도 함께: ' + JSON.stringify({ after, head: siteGit(S4, id, ['rev-parse', 'HEAD']), c }));
assert.equal(fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length, pushesBefore + 1, '원자적 푸시 한 번');
const saved = JSON.parse(fs.readFileSync(record, 'utf8')); assert.equal(saved.git.changes.find((x) => x.commit === commit).remote, 'verified', '기록도 저장됨'); assert.equal(saved.rev, site.rev, '조정은 내용 변경이 아니므로 rev 를 올리지 않는다(Suite)');
});
test('store.save 합치기 규칙(저장소 불변식): 읽은 뒤 바뀐 항목만 디스크 최신 기록 위에 합치되 중첩 객체는 잎 단위로 보고, 같은 잎(배열 포함)을 둘 다 다르게 바꾼 진짜 동시 변경은 site_save_conflict 로 거절해 디스크의 먼저 쓴 값을 보존한다(같은 값이면 충돌 아님) — update() 는 최신 기록을 다시 읽어 그 위에 적용하는 안전 경로', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-store-')); const id = 'a'.repeat(32);
const script = `
const store = require(${JSON.stringify(path.join(ROOT, 'lib', 'store.js'))}); const id = ${JSON.stringify(id)}; const out = {};
const tryCatch = (fn) => { try { return fn(); } catch (e) { return { code: e.code, status: e.status, fields: e.fields, message: e.message }; } };
store.save({ id, title: 'T0', tier: 'basic', plan: { brief: { assets: { logo: null, photos: [] } }, verification: null, stage: 'generated' }, attachments: [], git: { seq: 1, changes: [{ no: 1, pushed: false }] }, commands: [] });
let A = store.load(id), B = store.load(id); A.title = 'A-title'; store.save(A); B.tier = 'standard'; store.save(B);
out.r1 = { obj: [B.title, B.tier], disk: [store.load(id).title, store.load(id).tier] };
A = store.load(id); B = store.load(id); A.plan.brief.assets.logo = 'logo.png'; store.save(A); B.plan.verification = { at: 'x' }; store.save(B);
out.r2 = { obj: [B.plan.brief.assets.logo, B.plan.verification && B.plan.verification.at], disk: [store.load(id).plan.brief.assets.logo, store.load(id).plan.verification.at] };
A = store.load(id); B = store.load(id); A.title = 'E-title'; store.save(A); B.title = 'F-title'; out.r3 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).title, mine: B.title };
A = store.load(id); B = store.load(id); A.git.changes[0].pushed = true; store.save(A); B.git.changes.push({ no: 2 }); out.r4 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).git.changes };
A = store.load(id); B = store.load(id); A.tier = 'premium'; store.save(A); B.tier = 'premium'; out.r5 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).tier };
A = store.load(id); A.title = 'K-title'; store.save(A); const fresh = store.update(id, (f) => { f.git.changes.push({ no: 2, kind: 'later' }); f.status = 'idle'; }); out.r6 = { title: fresh.title, changes: fresh.git.changes.map((c) => c.no), disk: store.load(id).git.changes.map((c) => c.no) };
console.log(JSON.stringify(out));`;
const raw = execFileSync(process.execPath, ['-e', script], { env: { ...process.env, DATA_DIR: path.join(tmp, 'sites'), DATA_ROOT: tmp }, encoding: 'utf8' }); const out = JSON.parse(raw.trim().split('\n').pop());
assert.deepEqual(out.r1, { obj: ['A-title', 'standard'], disk: ['A-title', 'standard'] }, '다른 최상위 항목은 합쳐진다: ' + JSON.stringify(out.r1));
assert.deepEqual(out.r2, { obj: ['logo.png', 'x'], disk: ['logo.png', 'x'] }, '같은 객체 안의 다른 잎(plan.brief.assets.logo vs plan.verification)은 합쳐진다: ' + JSON.stringify(out.r2));
assert.equal(out.r3.thrown && out.r3.thrown.code, 'site_save_conflict', '같은 잎을 둘 다 다르게 바꾸면 거절: ' + JSON.stringify(out.r3)); assert.deepEqual(out.r3.thrown.fields, ['title']); assert.equal(out.r3.thrown.status, 409); assert.equal(out.r3.disk, 'E-title', '디스크의 먼저 쓴 값 보존'); assert.equal(out.r3.mine, 'F-title', '거절된 객체는 그대로');
assert.equal(out.r4.thrown && out.r4.thrown.code, 'site_save_conflict', '배열은 잎: git.changes 를 둘 다 바꾸면 거절: ' + JSON.stringify(out.r4)); assert.deepEqual(out.r4.thrown.fields, ['git.changes']); assert.deepEqual(out.r4.disk, [{ no: 1, pushed: true }], '디스크 보존');
assert.equal(out.r5.thrown, 'no-throw', '같은 값으로 바꾼 것은 충돌이 아니다: ' + JSON.stringify(out.r5)); assert.equal(out.r5.disk, 'premium');
assert.deepEqual(out.r6, { title: 'K-title', changes: [1, 2], disk: [1, 2] }, 'update() 는 최신 기록 위에 적용: ' + JSON.stringify(out.r6));
fs.rmSync(tmp, { recursive: true, force: true });
});
test('배경 AI 작업과 작업 중 허용되는 경로가 같은 잎을 바꾸는 진짜 동시 변경(작업 중 "다시 푸시" 가 git.changes 의 반영 상태를 바꾸고, 작업 종료가 git.changes 에 새 버전을 더함): 작업의 늦은 저장은 디스크를 덮어쓰지 않고 충돌을 받아 최신 기록 위에 자기 결과만 다시 적용한다 — 다시 푸시의 verified 와 작업의 새 버전·commands·status 가 모두 남는다', async () => {
const fakeWrite = path.join(os.tmpdir(), `fake-claude-write-${process.pid}.sh`); fs.writeFileSync(fakeWrite, '#!/bin/sh\ndate +%s%N >> ai-change.html\nsleep 30\n', { mode: 0o755 }); // 사이트 폴더(cwd)에 파일을 바꿔 두고 멈춘다 → 중단되어도 작업 종료가 버전을 기록한다
S5 = await boot('remote-busy', { CLAUDE_BIN: fakeWrite, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea', PATH: `${GITWRAP}:${process.env.PATH}` });
const { id } = await S5.mkSite(); const record = path.join(S5.tmp, 'sites', `${id}.json`);
const site = async () => (await S5.api(`/api/sites/${id}`, {}, 'a')).b; const waitIdle = async () => { for (let i = 0; i < 100; i++) { if (!(await site()).running) return; await new Promise((r) => setTimeout(r, 100)); } throw new Error('작업이 끝나지 않음'); };
const waitRunning = async () => { for (let i = 0; i < 50; i++) { if ((await site()).running) return; await new Promise((r) => setTimeout(r, 100)); } throw new Error('작업이 시작되지 않음'); };
try {
await waitRunning(); await S5.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); await waitIdle(); // 생성 작업(가짜 claude 가 붙잡음)은 끝낸다
const g = await S5.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const v = await S5.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">충돌 전 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v.status, 200, JSON.stringify(v.b));
let c = null; for (let i = 0; i < 60; i++) { c = ((await site()).git.changes || []).find((x) => x.commit === v.b.change.commit); if (c && c.remote === 'verified') break; await new Promise((r) => setTimeout(r, 100)); } assert.ok(c && c.remote === 'verified', JSON.stringify(c));
// 기록을 "아직 확인 안 됨" 으로 되돌리고 원격 변경 브랜치를 지운다 → 작업 중 "다시 푸시" 가 git.changes 를 실제로 바꾸게 된다
const rec0 = JSON.parse(fs.readFileSync(record, 'utf8')); const rc = rec0.git.changes.find((x) => x.commit === v.b.change.commit); rc.pushed = false; rc.remote = 'pending'; delete rc.verifiedAt; fs.writeFileSync(record, JSON.stringify(rec0, null, 2));
execFileSync('git', ['--git-dir', BARE, 'update-ref', '-d', `refs/heads/${v.b.change.branch}`]);
const n0 = ((await site()).commands || []).length;
const cmd = await S5.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '충돌 경쟁 작업' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b)); await waitRunning();
// 작업을 중단한다 → 작업 종료가 (중단 기록 저장 뒤) 버전 기록을 위해 커밋하는 동안(래퍼가 commit 을 2초 늦춤) "다시 푸시" 가 같은 git.changes 의 반영 상태를 바꾼다 → 작업의 그 다음 저장(새 버전 추가)은 같은 잎을 둘 다 바꾼 진짜 동시 변경
fs.writeFileSync(path.join(BARE, 'SLOW_COMMIT'), '');
await S5.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); await new Promise((r) => setTimeout(r, 700));
const retry = await S5.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b).slice(0, 300));
const rr = (retry.b.changes || []).find((x) => x.commit === v.b.change.commit); assert.ok(rr && rr.pushed === true && rr.remote === 'verified' && rr.verifiedAt, '커밋 중 다시 푸시가 기록을 바꿨다: ' + JSON.stringify(rr));
fs.unlinkSync(path.join(BARE, 'SLOW_COMMIT')); await waitIdle(); await new Promise((r) => setTimeout(r, 800));
const after = await site(); const kept = (after.git.changes || []).find((x) => x.commit === v.b.change.commit);
assert.ok(kept && kept.pushed === true && kept.remote === 'verified', '작업의 늦은 저장이 다시 푸시의 결과(verified)를 덮어쓰지 않는다: ' + JSON.stringify(kept));
assert.equal(kept.verifiedAt, rr.verifiedAt, '다시 푸시가 쓴 확인 시각이 그대로다(덮어썼다가 배경 재확인으로 다시 verified 가 된 것이 아니다): ' + JSON.stringify({ kept: kept.verifiedAt, retry: rr.verifiedAt }));
const last = after.git.changes[after.git.changes.length - 1]; assert.ok(last && last.kind === 'ai-update' && last.no > kept.no, '작업의 새 버전도 기록된다: ' + JSON.stringify(after.git.changes.map((x) => [x.no, x.kind, x.remote])));
assert.equal((after.commands || []).length, n0 + 1, 'commands 도 남는다'); assert.equal(after.running, false); assert.ok(['idle', 'error'].includes(after.status), after.status);
assert.equal(fs.readFileSync(record, 'utf8').includes('"remote": "verified"') || fs.readFileSync(record, 'utf8').includes('"remote":"verified"'), true, '디스크 기록에도 verified');
} finally { try { fs.unlinkSync(fakeWrite); } catch (_) {} try { fs.unlinkSync(path.join(BARE, 'SLOW_COMMIT')); } catch (_) {} pkillSleep(); }
});
test('gitstore.recordChangeStrict(직접 호출, 원격 없음): 첫 커밋 전 저장소에서 기록 저장이 실패하면 만들어진 커밋·브랜치를 지우고 add 로 올라간 인덱스를 비워 검증된 {ok:true} 를 돌려주고, onRollback 이 문제를 보고하면 ok:false 와 문제 목록을 돌려주며(거짓 성공 없음), 성공하면 pushed:false·원격 미설정 안내로 끝난다', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-gs-')); const site = path.join(tmp, 'www', 's1'); fs.mkdirSync(site, { recursive: true }); fs.writeFileSync(path.join(site, 'index.html'), '<p>a</p>');
const bare = path.join(tmp, 'origin.git'); execFileSync('git', ['init', '-q', '--bare', bare]);
fs.writeFileSync(path.join(bare, 'hooks', 'pre-receive'), `#!/bin/sh\necho push >> "${bare}/pushes.log"\nif [ -e "${bare}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
const script = `
const gs = require(${JSON.stringify(path.join(ROOT, 'lib', 'gitstore.js'))}); const fs = require('fs'); const site = ${JSON.stringify(site)}; const out = {};
const base = { siteId: 's1', siteDir: site, no: 1, kind: 'visual-edit', message: 'm', author: { name: 't', email: 't@x' } };
(async () => {
// 0) 준비 단계 실패(git init 불가 — 저장소 뿌리 읽기 전용): 페이지는 이미 바뀐 뒤이므로 onRollback 이 불리고, 만들다 만 git 폴더 없이 검증된 되돌리기
fs.chmodSync(${JSON.stringify(path.join(tmp, 'git'))}, 0o555); fs.writeFileSync(site + '/index.html', '<p>b</p>');
try { await gs.recordChangeStrict({ ...base, onCommitted: () => {}, onRollback: () => { fs.writeFileSync(site + '/index.html', '<p>a</p>'); return []; } }); out.r0 = 'no-throw'; } catch (e) { out.r0 = { stage: e.stage, rollback: e.rollback, message: e.message, gitDirExists: fs.existsSync(${JSON.stringify(path.join(tmp, 'git', 's1'))}), page: fs.readFileSync(site + '/index.html', 'utf8') }; }
fs.chmodSync(${JSON.stringify(path.join(tmp, 'git'))}, 0o755);
fs.writeFileSync(site + '/index.html', '<p>b</p>');
try { await gs.recordChangeStrict({ ...base, onCommitted: () => { throw Object.assign(new Error('store down'), { code: 'EACCES' }); }, onRollback: () => { fs.writeFileSync(site + '/index.html', '<p>a</p>'); return []; } }); out.r1 = 'no-throw'; } catch (e) { out.r1 = { stage: e.stage, rollback: e.rollback, message: e.message }; }
out.r1state = { page: fs.readFileSync(site + '/index.html', 'utf8') };
fs.writeFileSync(site + '/index.html', '<p>c</p>');
try { await gs.recordChangeStrict({ ...base, onCommitted: () => { throw new Error('store down'); }, onRollback: () => ['페이지 되돌리기 생략'] }); out.r2 = 'no-throw'; } catch (e) { out.r2 = { rollback: e.rollback }; }
const rec = await gs.recordChangeStrict({ ...base, onCommitted: () => {}, onRollback: () => [] }); out.r3 = { commit: !!(rec && rec.commit), sha: rec && rec.commit, no: rec && rec.no, pushed: rec && rec.pushed, error: rec && rec.error, branch: rec && rec.branch, remote: rec && rec.remote };
console.log(JSON.stringify(out));
})().catch((e) => { console.log(JSON.stringify({ fatal: e.stack })); });`;
const raw = execFileSync(process.execPath, ['-e', script], { env: { ...process.env, DATA_ROOT: tmp, SITES_GIT_REMOTE: '', SOURCE_PROVIDER: 'none' }, encoding: 'utf8' });
const out = JSON.parse(raw.trim().split('\n').pop()); assert.ok(!out.fatal, raw);
assert.equal(out.r0.stage, 'prepare', '준비 단계 실패도 트랜잭션 안: ' + JSON.stringify(out.r0)); assert.equal(out.r0.rollback && out.r0.rollback.ok, true, JSON.stringify(out.r0)); assert.equal(out.r0.page, '<p>a</p>', 'onRollback 이 불려 페이지 복원'); assert.equal(out.r0.gitDirExists, false, '만들다 만 git 폴더 없음');
// 4) 원격 반영 조정(원격이 설정된 두 번째 프로세스): 원격이 없던 때의 기록(또는 기록 저장과 푸시 사이에 죽은 pending 기록)을 ls-remote 로 확인 → 없으면 변경 브랜치 + main 원자적 푸시 → 다시 ref 로 확인한 뒤에만 pushed:true
const script2 = `
const gs = require(${JSON.stringify(path.join(ROOT, 'lib', 'gitstore.js'))}); const fs = require('fs'); const site = ${JSON.stringify(site)}; const bare = ${JSON.stringify(bare)}; const out = {};
(async () => {
const r4 = { no: ${JSON.stringify(out.r3.no)}, kind: 'visual-edit', branch: ${JSON.stringify(out.r3.branch)}, commit: ${JSON.stringify(out.r3.sha)}, pushed: false, error: null, remote: 'pending' };
fs.writeFileSync(bare + '/REJECT', '');
await gs.reconcilePushes('s1', site, [r4]); out.r4reject = { pushed: r4.pushed, remote: r4.remote, error: r4.error };
fs.unlinkSync(bare + '/REJECT');
await gs.reconcilePushes('s1', site, [r4]); out.r4 = { pushed: r4.pushed, remote: r4.remote, error: r4.error, verifiedAt: !!r4.verifiedAt };
const r5 = { ...r4, pushed: false, remote: 'pending', verifiedAt: undefined }; // 이미 원격에 있는 기록: 확인만 하고 푸시하지 않는다
await gs.reconcilePushes('s1', site, [r5]); out.r5 = { pushed: r5.pushed, remote: r5.remote };
console.log(JSON.stringify(out));
})().catch((e) => { console.log(JSON.stringify({ fatal: e.stack })); });`;
const raw2 = execFileSync(process.execPath, ['-e', script2], { env: { ...process.env, DATA_ROOT: tmp, SITES_GIT_REMOTE: bare, SOURCE_PROVIDER: 'gitea' }, encoding: 'utf8' });
const out2 = JSON.parse(raw2.trim().split('\n').pop()); assert.ok(!out2.fatal, raw2); Object.assign(out, out2);
const g = (...args) => execFileSync('git', args, { env: { ...process.env, GIT_DIR: path.join(tmp, 'git', 's1'), GIT_WORK_TREE: site }, encoding: 'utf8' }).trim();
assert.equal(out.r1.stage, 'metadata', JSON.stringify(out.r1)); assert.equal(out.r1.rollback.ok, true, '첫 커밋 전 되감기 검증: ' + JSON.stringify(out.r1)); assert.match(out.r1.message, /store down/); assert.equal(out.r1state.page, '<p>a</p>');
assert.equal(out.r2.rollback.ok, false, '문제가 보고되면 ok 가 아니다: ' + JSON.stringify(out.r2)); assert.ok(out.r2.rollback.problems.some((p) => /페이지 되돌리기 생략/.test(p)), JSON.stringify(out.r2));
assert.equal(out.r3.commit, true, JSON.stringify(out.r3)); assert.equal(out.r3.pushed, false); assert.match(out.r3.error, /소스 보관 연동 미설정/); assert.equal(out.r3.branch, 'sites/s1/changes/0001-visual-edit'); assert.equal(out.r3.remote, undefined, '원격이 없으면 pending 이 아니다');
assert.equal(g('rev-list', '--count', 'HEAD'), '1', '실패한 두 번의 커밋은 남지 않고 성공한 커밋 하나'); assert.equal(g('for-each-ref', '--format=%(refname)', 'refs/heads/').split('\n').sort().join(' '), 'refs/heads/main refs/heads/sites/s1/changes/0001-visual-edit');
assert.equal(g('status', '--porcelain'), '');
assert.deepEqual([out.r4reject.pushed, out.r4reject.remote], [false, 'pending'], '거부된 조정은 pending 그대로: ' + JSON.stringify(out.r4reject)); assert.match(out.r4reject.error || '', /pre-receive hook declined|rejected by test hook|거부/, '거부 사유가 기록됨: ' + JSON.stringify(out.r4reject));
assert.deepEqual([out.r4.pushed, out.r4.remote, out.r4.error, out.r4.verifiedAt], [true, 'verified', null, true], '조정 푸시 뒤 ref 로 확인: ' + JSON.stringify(out.r4));
const refs = Object.fromEntries(execFileSync('git', ['--git-dir', bare, 'for-each-ref', '--format=%(refname) %(objectname)'], { encoding: 'utf8' }).trim().split('\n').map((l) => l.split(' ')));
assert.equal(refs['refs/heads/sites/s1/changes/0001-visual-edit'], g('rev-parse', 'HEAD')); assert.equal(refs['refs/heads/sites/s1/main'], g('rev-parse', 'HEAD'));
assert.deepEqual([out.r5.pushed, out.r5.remote], [true, 'verified'], '이미 원격에 있으면 확인만: ' + JSON.stringify(out.r5));
assert.equal(fs.readFileSync(path.join(bare, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length, 2, '푸시는 거부 1 + 성공 1 — 확인만 한 조정은 푸시하지 않는다');
fs.rmSync(tmp, { recursive: true, force: true });
});
test('원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다', async () => {
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html');
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const before = fs.readFileSync(index, 'utf8');
fs.chmodSync(dir, 0o555); // 폴더에 새 파일을 만들 수 없음 — 기존 파일을 직접 덮어쓰는 방식이면 여기서도 "성공" 하며 실패 때 페이지가 잘릴 수 있다
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>쓰기 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(dir, 0o755); }
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /저장하지 못했습니다/); assert.match(r.b.error, /그대로/);
assert.equal(fs.readFileSync(index, 'utf8'), before, '기존 페이지 보존'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)), '임시 파일 잔여 없음');
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s.b.versions || []).filter((v) => v.kind === 'visual-edit').length, 0, '실패한 저장은 버전을 만들지 않는다');
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>다시 저장</h1>', 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: '<p>x</p>', 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: '<h1 id="h">작업 중 저장</h1>', 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: '<h1 id="h">읽기 중 저장</h1>', 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: '<h1 id="h">폐기 중 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'),
S6.api(`/api/sites/${A.id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE.replace('제목', '폐기 중 소스') }) }, 'a'),
S6.api(`/api/sites/${A.id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '폐기 중 작업' }) }, 'a'),
S6.api(`/api/sites/${A.id}`, { method: 'PATCH', body: JSON.stringify({ title: '폐기 중 제목' }) }, 'a'),
S6.api(`/api/sites/${A.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleA }) }, 'a'),
]);
for (const [i, r] of during.entries()) { assert.equal(r.status, 409, `폐기 중 변경 요청 #${i} 는 409: ` + JSON.stringify(r.b)); assert.match(r.b.error, /폐기/, `#${i} 폐기 안내`); }
assert.equal((await siteOf(A.id)).status, 200, '폐기 중 읽기는 된다'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', A.id, 'index.html')), '폐기 확정 전에는 파일이 있다');
const rd = await D; assert.equal(rd.status, 200, '폐기 성공: ' + JSON.stringify(rd.b)); assert.equal(rd.b.deleted, A.id);
assert.equal((await siteOf(A.id)).status, 404); assert.ok(!fs.existsSync(path.join(S6.tmp, 'www', A.id)), '사이트 폴더 삭제'); assert.ok(!fs.existsSync(path.join(S6.tmp, 'sites', `${A.id}.json`)), '기록 삭제');
assert.equal((await siteOf(B.id)).status, 200, '다른 사이트는 그대로'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', B.id, 'index.html')));
assert.ok(!fs.readFileSync(path.join(S6.tmp, 'www', B.id, 'index.html'), 'utf8').includes('폐기 중'), '409 된 저장은 어디에도 쓰이지 않았다');
// 2) 배포 서버 정리 실패: 표식을 풀고 기록만 남기며 사이트는 그대로 쓸 수 있다(저장·작업 시작 가능)
const C = await S6.mkSite(); await idle(S6, C.id); markDeployed(C.id); const titleC = (await siteOf(C.id)).b.title; fs.writeFileSync(path.join(FLAGS, 'SSH_FAIL'), '');
const rf = await S6.api(`/api/sites/${C.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleC }) }, 'a'); assert.ok(rf.status >= 400, '정리 실패는 오류: ' + JSON.stringify(rf.b)); assert.match(rf.b.error, /배포 서버/);
fs.unlinkSync(path.join(FLAGS, 'SSH_FAIL'));
const sc = await siteOf(C.id); assert.equal(sc.status, 200, '사이트 유지'); assert.ok(sc.b.deleteFailed && sc.b.deleteFailed.detail, '실패 기록'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', C.id, 'index.html')), '파일 유지');
const g2 = await S6.api(`/api/sites/${C.id}/visual/index.html`, {}, 'a'); const ok2 = await S6.api(`/api/sites/${C.id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">실패 뒤 저장</h1>', 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: '<h1 id="h">경쟁-설정</h1>', 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/<no>/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: '<h1 id="h">경쟁-복원</h1>', 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: '<h1 id="h">경쟁-작업</h1>', 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, /<button id="btnVisual"[^>]*aria-pressed="false"/);
for (const id of ['veBar', 'veState', 'veSave', 'veUndo', 'veRedo', 'veDiscard', 'veClose', 'vePalette', 'veInspector', 'vePaletteToggle', 'veInspectorToggle']) assert.ok(html.includes(`id="${id}"`), id);
assert.match(html, /id="veState"[^>]*aria-live="polite"/); assert.match(html, /id="vePalette"[^>]*role="region"[^>]*aria-label=/); assert.match(html, /id="veInspector"[^>]*role="region"[^>]*aria-label=/);
assert.ok(html.includes('id="btnToggleSource"'), '소스 편집기 유지'); assert.ok(html.includes('id="btnMobile"'), '모바일 미리보기 유지');
for (const f of ['/visual-editor-core.js', '/visual-editor-ui.js']) { assert.ok(html.includes(`src="${f}"`), f); const r = await fetch(S.B + f); assert.equal(r.status, 200, f); const t = await r.text(); assert.ok(t.length > 1000); }
const app = await (await fetch(S.B + '/app.js')).text(); assert.ok(app.includes("'visual-edit'"), '버전 목록에 직접 편집 종류'); assert.ok(app.includes('VisualEditor'), 'app.js 가 편집기와 연결');
const css = await (await fetch(S.B + '/style.css')).text(); assert.ok(css.includes('.ve-bar') && css.includes('.ve-side') && /@media \(max-width: 900px\)[^@]*\.ve-side/.test(css.replace(/\n/g, ' ')), '반응형 규칙');
});