Files

87 lines
6.7 KiB
JavaScript
Raw Permalink Normal View History

/**
* test/e2e/util.mjs — 로컬 종단 간(e2e) 검증 하네스 공통 도우미.
*
* - freePort() : 127.0.0.1 의 빈 포트 하나
* - waitFor(fn, ms) : 조건이 참이 될 때까지 폴링(프로세스 기동·작업 완료 대기)
* - makeClient(base) : 쿠키 항아리·CSRF 헤더를 가진 작은 HTTP 클라이언트(고객 포털·운영자 콘솔 공용)
* - signedClient(...) : lib/serviceauth 서명 호출(서비스 간 API 를 하네스가 직접 부를 때)
* - tarGz(dir, file) : 디렉터리를 tar.gz 로(릴리스 artifact·Webmaker 저장본 흉내)
* - spawnNode(args, env): 저장소의 node 프로세스를 띄우고 stdout/stderr 를 모아 둔다(보고서 증거용)
*/
import net from 'node:net';
import { spawn, execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { signHeaders } from '../../lib/serviceauth.mjs';
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 127.0.0.1 의 빈 포트 */
export function freePort() { return new Promise((resolve, reject) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); s.on('error', reject); }); }
/** 조건이 참(또는 값)이 될 때까지 기다린다. 시간이 다 되면 마지막 값을 돌려준다(예외 대신 — 보고서가 상태를 적는다). */
export async function waitFor(fn, { timeoutMs = 30000, intervalMs = 300, label = '' } = {}) {
const t0 = Date.now(); let last;
while (Date.now() - t0 < timeoutMs) { try { last = await fn(); if (last) return last; } catch (e) { last = { error: e.message, code: e.code }; } await sleep(intervalMs); }
return last && typeof last === 'object' ? { ...last, timedOut: true, label } : { timedOut: true, label, last };
}
/**
* 쿠키·CSRF 를 기억하는 HTTP 클라이언트. 응답 { status, json, text, headers, cookies }.
* 같은 base 의 Set-Cookie 를 항아리에 모으고 다음 요청에 보낸다. csrf 는 호출자가 setCsrf 로 넣는다(포털: /api/auth/csrf, HAL: 로그인 응답).
*/
export function makeClient(base, { ip = null } = {}) {
const jar = new Map(); let csrf = ''; const log = [];
function cookieHeader() { return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); }
async function req(method, path, body, headers = {}) {
const url = base.replace(/\/$/, '') + path;
const h = { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': 'ariweb-e2e/0.1', ...(csrf ? { 'X-CSRF': csrf } : {}), ...(ip ? { 'X-Real-IP': ip } : {}), ...headers };
const ck = cookieHeader(); if (ck) h.Cookie = ck;
let r; try { r = await fetch(url, { method, headers: h, body: body === undefined ? undefined : (typeof body === 'string' ? body : JSON.stringify(body)), redirect: 'manual', signal: AbortSignal.timeout(30000) }); }
catch (e) { const out = { status: 0, error: e.message, json: null, text: '', headers: {}, cookies: {} }; log.push({ method, path, status: 0, error: e.message }); return out; }
const setCookies = typeof r.headers.getSetCookie === 'function' ? r.headers.getSetCookie() : [].concat(r.headers.get('set-cookie') || []);
const cookies = {};
for (const c of setCookies) { const [kv, ...attrs] = c.split(';'); const i = kv.indexOf('='); const k = kv.slice(0, i).trim(); const v = kv.slice(i + 1).trim(); cookies[k] = decodeURIComponent(v); const maxAge = attrs.map((a) => a.trim()).find((a) => /^Max-Age=/i.test(a)); if (maxAge && Number(maxAge.split('=')[1]) <= 0) jar.delete(k); else jar.set(k, v); }
const text = await r.text(); let json = null; try { json = JSON.parse(text); } catch (_) {}
const out = { status: r.status, json, text, headers: Object.fromEntries(r.headers.entries()), cookies };
log.push({ method, path, status: r.status, error: json && json.ok === false ? json.error : undefined });
return out;
}
return { base, req, get: (p, h) => req('GET', p, undefined, h), post: (p, b, h) => req('POST', p, b ?? {}, h), put: (p, b, h) => req('PUT', p, b ?? {}, h), patch: (p, b, h) => req('PATCH', p, b ?? {}, h), del: (p, b, h) => req('DELETE', p, b, h), setCsrf: (v) => { csrf = v; }, cookie: (k) => jar.get(k), setCookie: (k, v) => jar.set(k, v), clear: () => { jar.clear(); csrf = ''; }, log };
}
/** 서비스 간 서명 호출(keyId·secret 로 lib/serviceauth 헤더를 만든다). */
export function signedClient(base, keyId, secret, extraHeaders = {}) {
return async function call(method, path, body) {
const text = body === undefined ? undefined : JSON.stringify(body);
const h = { ...signHeaders(keyId, secret, method, path, text), ...extraHeaders };
let r; try { r = await fetch(base.replace(/\/$/, '') + path, { method, headers: h, body: text, signal: AbortSignal.timeout(30000) }); } catch (e) { return { status: 0, error: e.message, json: null }; }
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch (_) {}
return { status: r.status, json, text: t };
};
}
/** 디렉터리를 tar.gz 로 묶는다(상대 경로 ./ 기준). */
export function tarGz(dir, file) { execFileSync('tar', ['-czf', file, '-C', dir, '.']); return file; }
/** 저장소의 node 스크립트 실행(동기, 출력 반환) — HAL CLI 등 */
export function runNode(args, env = {}, opts = {}) {
const r = execFileSync(process.execPath, ['--no-warnings', ...args], { cwd: ROOT, env: { ...process.env, ...env }, encoding: 'utf8', timeout: opts.timeoutMs || 60000, stdio: ['ignore', 'pipe', 'pipe'] });
return r;
}
/** 장기 실행 node 프로세스 띄우기. 출력은 메모리에 모은다(마지막 400줄). */
export function spawnNode(name, args, env = {}) {
const p = spawn(process.execPath, ['--no-warnings', ...args], { cwd: ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
const lines = []; const push = (s) => { for (const l of String(s).split('\n')) if (l.trim()) { lines.push(l); if (lines.length > 400) lines.shift(); } };
p.stdout.on('data', push); p.stderr.on('data', push);
const proc = { name, p, args, env, lines, exited: null };
p.on('exit', (code, signal) => { proc.exited = { code, signal, at: new Date().toISOString() }; });
proc.kill = (sig = 'SIGTERM') => { try { p.kill(sig); } catch (_) {} };
proc.waitExit = (ms = 5000) => waitFor(() => proc.exited, { timeoutMs: ms, intervalMs: 100 });
return proc;
}
/** 민감 정보를 빼고 증거로 남길 짧은 JSON */
export function brief(v, max = 600) { try { const s = JSON.stringify(v); return s.length > max ? s.slice(0, max) + '…' : s; } catch (_) { return String(v); } }