[verified] feat: add Webmaker visual editor v0.2.0

This commit is contained in:
oz
2026-10-28 15:07:35 +00:00
parent 4b3714e37c
commit 0a56d4612c
40 changed files with 4770 additions and 191 deletions
+55 -2
View File
@@ -7,6 +7,7 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ariweb-')); process.env.DA
fs.mkdirSync(path.join(tmp, 'sites'), { recursive: true }); fs.mkdirSync(path.join(tmp, 'www'), { recursive: true });
const SECRET = 'test-shared-secret-' + crypto.randomBytes(8).toString('hex');
const B = `http://127.0.0.1:${PORT}`;
const SSHWRAP = path.join(tmp, 'sshwrap'); const SSHLOG = path.join(tmp, 'ssh-calls.log');
const sha256 = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
const sign = (key, d) => crypto.createHmac('sha256', key).update(d).digest('base64url');
/** 포털이 하는 서비스 서명 호출 */
@@ -38,12 +39,16 @@ test.before(async () => {
// 보관소에 공유 비밀·설정에 포털 주소를 넣고 서버를 띄운다
const vault = require(ROOT + '/lib/vault'); vault.set({ kind: 'ariweb', id: 'ariweb', label: 'test', url: '', secret: SECRET, settings: {} });
const config = require(ROOT + '/lib/config'); config.set({ 'ariweb.portalUrl': `http://127.0.0.1:${portal.address().port}` }, 'test');
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, SOURCE_PROVIDER: 'none' } });
// 가짜 배포 대상 + 가짜 ssh(PATH 앞): 폐기의 배포 서버 정리를 결정적으로 늦추거나(1초) 실패시킨다(SSH_FAIL). 호출마다 로그를 남겨 "정리가 실제로 수행됐는지" 를 본다
fs.mkdirSync(SSHWRAP, { recursive: true });
fs.writeFileSync(path.join(SSHWRAP, 'ssh'), `#!/bin/sh\necho call >> "${SSHLOG}"\nsleep 1\nif [ -e "${tmp}/SSH_FAIL" ]; then echo "ssh: connect to host fake.test: Connection refused (test)" >&2; exit 255; fi\necho REMOVED\n`, { mode: 0o755 });
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() }] }));
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, SOURCE_PROVIDER: 'none', PATH: `${SSHWRAP}:${process.env.PATH}` } });
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
for (let i = 0; i < 150; i++) { if (/listening on/.test(out)) break; await new Promise((r) => setTimeout(r, 100)); }
assert.match(out, /listening on/);
});
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} try { portal.close(); } catch (_) {} });
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} try { portal.close(); } catch (_) {} try { fs.chmodSync(tmp, 0o755); } catch (_) {} });
let projectId; let cookie;
test('내부 API: 서명 없는 호출 거부 · 프로젝트 만들기·연결·조회·소스·export', async () => {
@@ -91,3 +96,51 @@ test('폐기: 포털 영향 확인 → 확인 전 409(영향 표시) → 운영
const r3 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목', acknowledgeImpact: true }) }); assert.equal(r3.status, 200, await r3.text());
assert.equal(require(ROOT + '/lib/ariweb').linkOf(projectId), null);
});
/** 포털에 연결된 프로젝트를 만들고 index.html 과 "배포됨" 기록(가짜 배포 대상 t1)을 둔다 → 폐기가 배포 서버 정리(가짜 ssh)를 거친다 */
async function mkLinked(title, siteId) {
const c = await svc('POST', '/api/internal/ariweb/projects', { owner: 'aw-g1-u1', ownerName: '테스트', title, mode: 'manual', link: { siteId, groupId: 'g_1', siteName: '우리 사이트', groupName: '우리 그룹', portalUrl: 'http://127.0.0.1:' + portal.address().port } });
assert.equal(c.status, 200, JSON.stringify(c.b)); const id = c.b.project.id; const dir = path.join(tmp, 'www', id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.html'), `<h1>${title}</h1>`);
const f = path.join(tmp, 'sites', `${id}.json`); const rec = JSON.parse(fs.readFileSync(f, 'utf8')); rec.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(rec, null, 2));
return { id, dir, record: f };
}
const sshCalls = () => { try { return fs.readFileSync(SSHLOG, 'utf8').split('\n').filter(Boolean).length; } catch (_) { return 0; } };
const del = (id, title) => fetch(B + `/api/sites/${id}`, { method: 'DELETE', headers: { cookie, 'Content-Type': 'application/json', referer: B + '/' }, body: JSON.stringify({ confirm: title, acknowledgeImpact: true }) });
test('폐기 보상 1 — 배포 서버 정리가 실패하면 포털 연결은 풀리지 않는다(연결 해제는 정리 성공 뒤·되돌릴 수 없는 삭제 직전에만): 사이트 파일·기록·연결이 모두 그대로이고 deleteFailed 가 남는다', async () => {
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
const P = await mkLinked('보상-정리실패', 's_c1'); const a = require(ROOT + '/lib/ariweb'); assert.ok(a.linkOf(P.id), '연결됨'); const before = fs.readFileSync(P.record, 'utf8'); const n0 = sshCalls();
fs.writeFileSync(path.join(tmp, 'SSH_FAIL'), '');
const r = await del(P.id, '보상-정리실패'); const j = await r.json().catch(() => ({})); fs.unlinkSync(path.join(tmp, 'SSH_FAIL'));
assert.ok(r.status >= 400, '정리 실패는 오류: ' + r.status + ' ' + JSON.stringify(j)); assert.match(j.error || '', /배포 서버/); assert.ok(sshCalls() > n0, '정리를 실제로 시도했다');
assert.ok(a.linkOf(P.id), '포털 연결이 풀리지 않았다(연결 해제는 정리 성공 뒤에만): ' + JSON.stringify(a.linkOf(P.id))); assert.equal(a.linkOf(P.id).siteId, 's_c1');
assert.ok(fs.existsSync(path.join(P.dir, 'index.html')), '사이트 파일 그대로'); const after = JSON.parse(fs.readFileSync(P.record, 'utf8')); assert.ok(after.deleteFailed && after.deleteFailed.detail, '실패 기록'); assert.equal(after.title, JSON.parse(before).title);
const ok = await del(P.id, '보상-정리실패'); assert.equal(ok.status, 200, '정리가 되면 폐기 성공: ' + await ok.text()); assert.equal(a.linkOf(P.id), null); assert.ok(!fs.existsSync(P.dir));
});
test('폐기 보상 2 — 정리는 성공했는데 포털 연결 해제(연결 등록부 쓰기)가 실패하면 되돌릴 수 없는 삭제로 가지 않는다: 사이트 파일·기록이 그대로(임시 이름으로 옮긴 것도 되돌림)이고 연결도 그대로, 명시적 실패', async () => {
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
const P = await mkLinked('보상-연결해제실패', 's_c2'); const a = require(ROOT + '/lib/ariweb'); const n0 = sshCalls();
fs.chmodSync(tmp, 0o555); // DATA_ROOT 루트를 읽기 전용으로 → 연결 등록부(ariweb-links.json)의 원자적 쓰기(임시 파일)가 실패한다. sites/·www/ 는 하위 폴더라 그대로 쓸 수 있다
let r, j; try { r = await del(P.id, '보상-연결해제실패'); j = await r.json().catch(() => ({})); } finally { fs.chmodSync(tmp, 0o755); }
assert.ok(r.status >= 400, '연결 해제 실패는 오류: ' + r.status + ' ' + JSON.stringify(j)); assert.ok(sshCalls() > n0, '정리는 먼저 수행됐다');
assert.ok(a.linkOf(P.id) && a.linkOf(P.id).siteId === 's_c2', '연결 그대로'); assert.ok(fs.existsSync(path.join(P.dir, 'index.html')), '사이트 파일 그대로(임시 이름 아님)'); assert.ok(fs.existsSync(P.record), '기록 그대로');
assert.ok(!fs.readdirSync(path.join(tmp, 'www')).some((d) => d.startsWith('.deleting-')), '임시로 옮긴 폴더가 남지 않는다');
const g = await fetch(B + `/api/sites/${P.id}`, { headers: { cookie, referer: B + '/' } }); assert.equal(g.status, 200, '사이트는 그대로 쓸 수 있다'); const rec = JSON.parse(fs.readFileSync(P.record, 'utf8')); assert.ok(rec.deleteFailed, '실패 기록');
const ok = await del(P.id, '보상-연결해제실패'); assert.equal(ok.status, 200, '쓸 수 있게 되면 폐기 성공: ' + await ok.text()); assert.equal(a.linkOf(P.id), null);
});
test('폐기 보상 3 — 연결 해제 뒤 되돌릴 수 없는 단계는 기록 삭제 한 번이고, 그 뒤 뒷정리(사이트 폴더 지우기)가 실패해도 성공으로 꾸미거나 삼키지 않는다: 사이트·기록·연결은 모두 없고 남은 폴더는 warnings·감사 기록으로 운영자에게 보인다; 보상(되살리기)은 readback 으로 확인된다', async () => {
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
const a = require(ROOT + '/lib/ariweb');
// 보상 함수 자체: 되살리기 뒤 readback 이 원래 연결과 같다(시각 보존)
const l0 = { siteId: 's_x', groupId: 'g_x', portalUrl: 'http://p', siteName: 'n', groupName: 'g', linkedAt: '2026-01-01T00:00:00.000Z' }; a.link('p_comp', l0); a.unlink('p_comp'); assert.equal(a.linkOf('p_comp'), null); a.link('p_comp', l0); assert.deepEqual(a.linkOf('p_comp'), l0, 'readback'); a.unlink('p_comp');
const P = await mkLinked('보상-뒷정리실패', 's_c3');
const locked = path.join(P.dir, 'locked'); fs.mkdirSync(locked); fs.writeFileSync(path.join(locked, 'keep.txt'), 'x'); fs.chmodSync(locked, 0o555); // 안의 파일을 지울 수 없는 폴더 → 옮겨 둔 폴더의 뒷정리(rmSync)가 실패한다
let r, j, trash; try { r = await del(P.id, '보상-뒷정리실패'); j = await r.json().catch(() => ({})); trash = fs.readdirSync(path.join(tmp, 'www')).filter((d) => d.startsWith('.deleting-')); } finally { try { fs.chmodSync(locked, 0o755); } catch (_) {} for (const d of trash || []) { try { fs.chmodSync(path.join(tmp, 'www', d, 'locked'), 0o755); } catch (_) {} } }
assert.equal(r.status, 200, '연결 해제·기록 삭제까지 끝났으면 폐기는 성공: ' + JSON.stringify(j)); assert.equal(j.deleted, P.id); assert.ok(Array.isArray(j.warnings) && j.warnings.some((w) => /폴더 정리 실패/.test(w)), '남은 폴더는 경고로 보인다: ' + JSON.stringify(j.warnings));
assert.equal(a.linkOf(P.id), null, '연결 없음'); assert.ok(!fs.existsSync(P.record), '기록 없음'); assert.ok(!fs.existsSync(P.dir), '원래 이름의 사이트 폴더 없음');
assert.equal(trash.length, 1, '옮겨 둔 폴더가 운영자 확인용으로 남아 있다: ' + JSON.stringify(trash)); assert.ok(fs.existsSync(path.join(tmp, 'www', trash[0], 'locked', 'keep.txt')));
const g = await fetch(B + `/api/sites/${P.id}`, { headers: { cookie, referer: B + '/' } }); assert.equal(g.status, 404);
fs.rmSync(path.join(tmp, 'www', trash[0]), { recursive: true, force: true });
});