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