Files

94 lines
12 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 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');
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' } });
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 (_) {} });
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);
});