Files

147 lines
21 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// [Ariweb Suite 변경] Ariweb Suite 연동 회귀 테스트 — lib/ariweb(티켓·서비스 서명·연결 등록부), store.rev, 서버 단위(SSO 진입·내부 API·동시 편집 충돌·발행 안내·폐기 영향).
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 crypto = require('crypto'); const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..'); const PORT = 4100 + Math.floor(Math.random() * 90);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ariweb-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www');
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');
/** 포털이 하는 서비스 서명 호출 */
const svc = async (method, p, body) => { const text = body === undefined ? undefined : JSON.stringify(body); const ts = String(Date.now()); const r = await fetch(B + p, { method, headers: { 'Content-Type': 'application/json', 'X-Service-Key-Id': 'portal', 'X-Service-Ts': ts, 'X-Service-Sign': sign(SECRET, `${method}|${p}|${ts}|${sha256(text)}`) }, body: text }); const ct = r.headers.get('content-type') || ''; return { status: r.status, b: ct.includes('json') ? await r.json() : null, r }; };
const ticket = (data) => { const payload = Buffer.from(JSON.stringify({ v: 1, exp: Date.now() + 60000, nonce: crypto.randomBytes(8).toString('hex'), ...data })).toString('base64url'); return `${payload}.${sign(SECRET, payload)}`; };
let child; let portal; let portalImpact = { impact: { blocked: false, liveSite: true, lifecycle: 'live', deployments: 2, domains: ['example.test'], message: '운영 사이트가 연결되어 있습니다' } }; let portalCalls = [];
test('lib/ariweb: 티켓 검증(서명·만료·1회용)과 서비스 서명 검증', () => {
const a = require(ROOT + '/lib/ariweb');
const t = a.makeTicket({ login: 'aw-g1-u1', name: 'U', groupId: 'g1' }, { key: SECRET }); const ok = a.verifyTicket(t, { key: SECRET }); assert.equal(ok.login, 'aw-g1-u1');
assert.match(a.verifyTicket(t, { key: SECRET }).error, /이미 사용/); assert.match(a.verifyTicket(t + 'x', { key: SECRET }).error, /서명/);
assert.match(a.verifyTicket(a.makeTicket({ login: 'x', exp: Date.now() - 1 }, { key: SECRET }), { key: SECRET }).error, /만료/);
assert.match(a.verifyTicket(a.makeTicket({ login: 'x' }, { key: 'other' }), { key: SECRET }).error, /서명/);
assert.match(a.verifyTicket(t, { key: '' }).error, /설정되지/);
const req = (h) => ({ method: 'POST', headers: h }); const ts = String(Date.now()); const body = '{"a":1}';
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': ts, 'x-service-sign': sign(SECRET, `POST|/api/internal/ariweb/health|${ts}|${sha256(body)}`) }), '/api/internal/ariweb/health', body, { key: SECRET }).ok, true);
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': ts, 'x-service-sign': 'bad' }), '/x', body, { key: SECRET }).ok, false);
assert.equal(a.verifyService(req({ 'x-service-key-id': 'portal', 'x-service-ts': String(Date.now() - 10 * 60000), 'x-service-sign': 'x' }), '/x', body, { key: SECRET }).reason, 'clock_skew');
assert.equal(a.linkOf('p1'), null); a.link('p1', { siteId: 's1', groupId: 'g1', portalUrl: 'https://my.example.test' }); assert.equal(a.linkOf('p1').siteId, 's1'); assert.equal(a.unlink('p1'), true); assert.equal(a.linkOf('p1'), null);
});
test('store.save: rev 가 저장마다 1 오른다', () => {
const store = require(ROOT + '/lib/store'); const id = crypto.randomBytes(16).toString('hex'); const s = store.create({ id, title: 't' }); assert.equal(s.rev, 1); store.save(s); assert.equal(store.load(id).rev, 2);
});
test.before(async () => {
// 가짜 포털(프로젝트 삭제 영향 응답) — Webmaker 가 서명한 요청만 받는다
portal = http.createServer((req, res) => { let d = ''; req.on('data', (c) => { d += c; }); req.on('end', () => { portalCalls.push({ url: req.url, keyId: req.headers['x-service-key-id'], hasSign: !!req.headers['x-service-sign'], body: d }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(portalImpact)); }); });
await new Promise((r) => portal.listen(0, '127.0.0.1', r));
// 보관소에 공유 비밀·설정에 포털 주소를 넣고 서버를 띄운다
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');
// 가짜 배포 대상 + 가짜 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 (_) {} try { fs.chmodSync(tmp, 0o755); } catch (_) {} });
let projectId; let cookie;
test('내부 API: 서명 없는 호출 거부 · 프로젝트 만들기·연결·조회·소스·export', async () => {
assert.equal((await fetch(B + '/api/internal/ariweb/health')).status, 401);
assert.equal((await svc('GET', '/api/internal/ariweb/health')).b.ok, true);
const c = await svc('POST', '/api/internal/ariweb/projects', { owner: 'aw-g1-u1', ownerName: '테스트', title: '포털 사이트', mode: 'manual', link: { siteId: 's_1', groupId: 'g_1', siteName: '우리 사이트', groupName: '우리 그룹', portalUrl: 'http://127.0.0.1:' + portal.address().port } });
if (c.status !== 200) console.log('DEBUG create', c.status, JSON.stringify(c.b), await c.r.text().catch(() => '')); assert.equal(c.status, 200, JSON.stringify(c.b)); projectId = c.b.project.id; assert.equal(c.b.project.link.siteId, 's_1'); assert.equal(c.b.project.hasContent, false);
fs.writeFileSync(path.join(tmp, 'www', projectId, 'index.html'), '<h1>hello</h1>');
const g = await svc('GET', `/api/internal/ariweb/projects/${projectId}`); assert.equal(g.b.project.hasContent, true); assert.ok(Array.isArray(g.b.project.versionList)); assert.equal(g.b.project.git.provider, 'none');
const l = await svc('GET', '/api/internal/ariweb/projects?owner=aw-g1-u1'); assert.equal(l.b.projects.length, 1);
const src = await svc('GET', `/api/internal/ariweb/projects/${projectId}/source`); assert.equal(src.b.ok, true); assert.ok(src.b.source.siteBytes > 0); assert.equal(src.b.source.lfsSupported, false);
const ex = await svc('GET', `/api/internal/ariweb/projects/${projectId}/export`); assert.equal(ex.status, 200); assert.equal(ex.r.headers.get('x-ariweb-version'), 'current'); const buf = Buffer.from(await ex.r.arrayBuffer()); assert.ok(buf.length > 20 && buf[0] === 0x1f && buf[1] === 0x8b, 'gzip');
assert.equal((await svc('GET', `/api/internal/ariweb/projects/${projectId}/export?version=99`)).status, 404);
assert.equal((await svc('GET', '/api/internal/ariweb/credits/aw-g1-u1')).b.credit.balance, 0);
});
test('SSO: 티켓으로 세션 발급·프로젝트로 이동·배너 컨텍스트, 재사용·위조 거부', async () => {
const t = ticket({ login: 'aw-g1-u1', name: '테스트', groupId: 'g_1', groupName: '우리 그룹', siteId: 's_1', siteName: '우리 사이트', projectId });
const r = await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t), { redirect: 'manual' }); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), `/?site=${projectId}`);
const sc = r.headers.getSetCookie(); assert.ok(sc.some((c) => c.startsWith('wh_session=')) && sc.some((c) => c.startsWith('wm_ariweb=')));
cookie = sc.map((c) => c.split(';')[0]).join('; ');
const meR = await fetch(B + '/api/auth/me', { headers: { cookie, referer: B + '/' } }); const me = await meR.json(); if (!me.user) console.log('DEBUG me', meR.status, JSON.stringify(me), cookie); assert.equal(me.user.id, 'aw-g1-u1');
const cfg = await (await fetch(B + '/api/config', { headers: { cookie, referer: B + '/' } })).json(); assert.equal(cfg.ariweb.groupName, '우리 그룹'); assert.equal(cfg.ariweb.siteName, '우리 사이트');
assert.equal((await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t), { redirect: 'manual' })).status, 403, '1회용');
assert.equal((await fetch(B + '/api/auth/sso?ticket=' + encodeURIComponent(t.slice(0, -2) + 'zz'), { redirect: 'manual' })).status, 403, '위조');
});
test('동시 편집 충돌: 옛 rev 로 저장하면 409 + 두 버전 요약, 최신 rev 는 저장', async () => {
const H = { cookie, 'Content-Type': 'application/json', referer: B + '/' };
const s0 = await (await fetch(B + `/api/sites/${projectId}`, { headers: H })).json(); const rev = s0.rev; assert.ok(rev >= 1); assert.equal(s0.ariweb.siteId, 's_1');
const ok = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ title: '바뀐 제목', rev }) }); assert.equal(ok.status, 200);
const stale = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ title: '다른 창', rev }) }); assert.equal(stale.status, 409); const j = await stale.json(); assert.equal(j.conflict.yourRev, rev); assert.ok(j.conflict.currentRev > rev);
const stale2 = await fetch(B + `/api/sites/${projectId}/files/index.html`, { method: 'PUT', headers: { ...H, 'If-Match': String(rev) }, body: JSON.stringify({ content: '<h1>x</h1>' }) }); assert.equal(stale2.status, 409);
const noRev = await fetch(B + `/api/sites/${projectId}`, { method: 'PATCH', headers: H, body: JSON.stringify({ purpose: '옛 화면' }) }); assert.equal(noRev.status, 200, 'rev 없는 옛 화면은 예전처럼');
});
test('발행: 포털 연결 프로젝트는 409 + 포털 배포 화면 안내', async () => {
const r = await fetch(B + `/api/sites/${projectId}/plan/publish`, { method: 'POST', headers: { cookie, 'Content-Type': 'application/json', referer: B + '/' }, body: '{}' });
assert.equal(r.status, 409); const j = await r.json(); assert.match(j.error, /포털/); assert.match(j.ariweb.deployUrl, /\/sites\/s_1\/deploy$/);
});
test('폐기: 포털 영향 확인 → 확인 전 409(영향 표시) → 운영 중이면 차단 → 확인 후 폐기(포털 사이트는 건드리지 않음)', async () => {
const H = { cookie, 'Content-Type': 'application/json', referer: B + '/' };
const r1 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목' }) }); assert.equal(r1.status, 409); const j1 = await r1.json(); assert.equal(j1.ariweb.needAcknowledge, true); assert.equal(j1.ariweb.impact.deployments, 2);
assert.equal(portalCalls[0].keyId, 'webmaker'); assert.ok(portalCalls[0].hasSign); assert.equal(JSON.parse(portalCalls[0].body).siteId, 's_1');
portalImpact = { impact: { blocked: true, liveSite: true, lifecycle: 'live', deployments: 2, domains: [], message: '운영 중' } };
const r2 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목', acknowledgeImpact: true }) }); assert.equal(r2.status, 409);
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
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 });
});