'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'), '

hello

'); 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: '

x

' }) }); 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); });