로컬 E2E 검증 하네스(실제 HAL·포털·Agent 4대 + 가짜 Webmaker·AriMail·메일)와 34개 완료 조건 흐름 기록

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 13:10:31 +00:00
co-authored by Claude Fable 5.1
parent 1049f7b95c
commit 7662d06009
19 changed files with 4962 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
/**
* test/e2e.test.mjs — 로컬 종단 간 검증 진입점.
*
* 실제 HAL·포털·Node Agent 프로세스와 가짜 Webmaker/AriMail/메일 서버를 띄우고 test/e2e/flows/*.mjs 의 흐름을 순서대로 실행한 뒤
* docs/verification/local-e2e-<날짜>.md/.json 에 흐름별 판정과 결함을 기록한다.
* ARIWEB_E2E=0 건너뜀
* ARIWEB_E2E_FLOWS=a,b 특정 흐름 그룹만(파일 이름)
* ARIWEB_E2E_STRICT=1 'failed' 판정이 있으면 종료 코드 1(기본은 보고서만 쓰고 통과 — 결함은 보고서가 알린다)
* ARIWEB_E2E_KEEP=1 임시 디렉터리 보존
* 전체 5분 안에 끝나도록 흐름마다 시간 제한을 둔다.
*/
import { test, ok, done } from '../lib/test.mjs';
import { readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bootEnv } from './e2e/boot.mjs';
import { createReport } from './e2e/report.mjs';
if (process.env.ARIWEB_E2E === '0') { console.log(' (ARIWEB_E2E=0 — 로컬 종단 간 검증 건너뜀)'); done(); }
else {
const HERE = dirname(fileURLToPath(import.meta.url)); const report = createReport(); const startedAt = Date.now(); const log = (...a) => console.log(' ', ...a);
const only = (process.env.ARIWEB_E2E_FLOWS || '').split(',').map((s) => s.trim()).filter(Boolean);
const files = readdirSync(join(HERE, 'e2e', 'flows')).filter((f) => f.endsWith('.mjs')).sort().filter((f) => !only.length || only.some((o) => f.includes(o)));
let env = null; const state = {};
await test('환경 기동: HAL·Agent 4대·포털·가짜 서비스', async () => {
env = await bootEnv({ log }); report.env.agents = env.agents.length; report.env.ports = env.ports; report.env.mailServerRegistered = env.mailServerRegistered; report.env.node = process.version;
ok(env.hal.proc.exited === null, 'HAL 실행 중'); ok(env.portal.proc.exited === null, '포털 실행 중');
const h = await env.ops.get('/api/health'); ok(h.json.bootstrap.complete, '부트스트랩 완료: ' + JSON.stringify(h.json.bootstrap));
if (!env.mailServerRegistered) report.bug({ title: '운영자 중계로 AriMail 서버 등록 실패', file: 'portal/mail/api.mjs', route: 'POST /ops/api/portal/mail/servers → /internal/ops/mail/servers', expected: '200 server', actual: env.mailServerResponse, repro: 'boot.mjs 의 AriMail 서버 등록' });
});
for (const f of files) {
await test(`흐름 그룹 ${f}`, async () => {
if (!env) throw new Error('환경 없음'); const mod = await import(join(HERE, 'e2e', 'flows', f));
const budget = Number(mod.BUDGET_MS || 60000); let timer; const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`흐름 그룹 ${f} 시간 초과(${budget}ms)`)), budget); });
try { await Promise.race([mod.run({ env, report, state, log }), timeout]); } finally { clearTimeout(timer); }
log(`${f}: ${Math.round((Date.now() - startedAt) / 1000)}s 경과`);
});
}
await test('보고서 기록', async () => {
const out = report.write(join(HERE, '..', 'docs', 'verification')); log(`보고서: ${out.md}`); log('요약: ' + JSON.stringify(out.summary)); log('결함: ' + report.bugs.length + '건');
if (process.env.ARIWEB_E2E_STRICT === '1') ok(out.summary.failed === 0, `failed 판정 ${out.summary.failed}건`);
});
if (env) await env.stop();
done();
}
+105
View File
@@ -0,0 +1,105 @@
/**
* test/e2e/boot.mjs — 로컬 종단 간 검증 환경 기동.
*
* 한 번의 실행에서 다음을 임시 디렉터리(test/.tmp/e2e-*)와 빈 포트로 띄운다.
* 1) 가짜 서비스: 발신 메일 포획(Resend 호환) · Webmaker 연동 면 · AriMail 연동 면
* 2) HAL(실제 프로세스): 보관소에 서비스 키를 미리 넣고, CLI 로 설정 토큰·릴리스 등록(승인)·부트스트랩 릴리스 기록 → 서버 실행 → 운영자 최초 설정·로그인
* 3) Node Agent(실제 프로세스, ARIWEB_AGENT_STUB_CMDS=1): control/web/backup/web 역할 서버 4대를 등록·승인(첫 승인으로 부트스트랩 완료)
* 4) 고객 포털(실제 프로세스): HAL·Webmaker·AriMail 주소를 가짜에 맞추고 Catalog 자동 시드, AriMail 서버를 운영자 중계로 등록
* 서비스 키 배치: HAL 보관소 service:portal / service:hal 과 포털 보관소 service:hal / service:portal 에 모두 같은 값을 둔다
* (HAL CLI link-portal 은 두 값을 다르게 만드는데, 포털은 양방향에 'service:hal' 하나를 쓰므로 같은 값이어야 중계가 된다 — 보고서의 결함 항목 참고).
*/
import { mkdirSync, writeFileSync, rmSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { createHash, randomBytes } from 'node:crypto';
import { openVault } from '../../lib/vault.mjs';
import { ROOT, freePort, waitFor, makeClient, signedClient, tarGz, runNode, spawnNode, sleep } from './util.mjs';
import { startFakeMail } from './fake-mail.mjs';
import { startFakeWebmaker } from './fake-webmaker.mjs';
import { startFakeArimail } from './fake-arimail.mjs';
const rnd = (n = 24) => randomBytes(n).toString('base64url');
/** Agent 하나 등록·실행. role: control|web|backup, external 이면 사이트 귀속 */
export async function startAgent(env, { name, role = 'web', token = null, external = false, siteId = null, failHealth = false }) {
const dir = join(env.base, 'agents', name); mkdirSync(dir, { recursive: true });
const roots = { sites: join(dir, 'sites'), srv: join(dir, 'srv'), nginx: join(dir, 'nginx'), backups: join(dir, 'backups'), logs: join(dir, 'logs'), acme: join(dir, 'acme'), le: join(dir, 'le') };
for (const r of Object.values(roots)) mkdirSync(r, { recursive: true }); mkdirSync(join(roots.nginx, 'sites-available'), { recursive: true }); mkdirSync(join(roots.nginx, 'sites-enabled'), { recursive: true });
const aenv = { ARIWEB_AGENT_DATA: join(dir, 'data'), ARIWEB_SITES_ROOT: roots.sites, ARIWEB_SRV_ROOT: roots.srv, ARIWEB_NGINX_DIR: roots.nginx, ARIWEB_BACKUP_ROOT: roots.backups, ARIWEB_LOG_DIR: roots.logs, ARIWEB_ACME_WEBROOT: roots.acme, ARIWEB_LE_DIR: roots.le, ARIWEB_WILDCARD_CERT: '', ARIWEB_PLATFORM_DOMAIN: 'localhost', ARIWEB_MAINT_PAGE: '', ARIWEB_AGENT_STUB_CMDS: '1', ARIWEB_AGENT_HEARTBEAT_MS: '3000', USER: process.env.USER || 'master' };
let serverId = null; let regToken = token;
if (!external) { const r = await env.ops.post('/ops/api/servers', { name, role, location: 'e2e' }); if (r.status !== 200) throw new Error(`서버 생성 실패 ${r.status} ${r.text.slice(0, 200)}`); serverId = r.json.server.id; const t = await env.ops.post(`/ops/api/servers/${serverId}/token`); regToken = t.json.token; }
const out = runNode(['agent/index.mjs', 'register', '--hal', env.hal.url, '--token', regToken, '--role', role, '--label', name, ...(external ? ['--external', '--site', siteId] : [])], aenv);
const agentId = (out.match(/agent (ag_[a-z0-9]+)/) || [])[1];
const proc = spawnNode(`agent:${name}`, ['agent/index.mjs', 'run'], aenv);
const a = { name, role, dir, roots, env: aenv, serverId, agentId, proc, external, siteId, restart() { a.proc.kill('SIGKILL'); a.proc = spawnNode(`agent:${name}`, ['agent/index.mjs', 'run'], aenv); return a.proc; } };
env.agents.push(a);
if (!external) { await waitFor(async () => { const s = await env.ops.get(`/ops/api/servers/${serverId}`); return s.json?.server?.agentId ? s : null; }, { timeoutMs: 15000, intervalMs: 300 }); const ap = await env.ops.post(`/ops/api/servers/${serverId}/approve`); if (ap.status !== 200) throw new Error(`서버 승인 실패 ${ap.text.slice(0, 200)}`); }
else { await waitFor(async () => { const s = await env.ops.get('/ops/api/servers'); return s.status === 200 ? s : null; }, { timeoutMs: 5000 }); }
return a;
}
/** 릴리스 artifact(작은 tar.gz)와 manifest 를 만든다. 모듈 디렉터리에 package.json(version) 이 있어야 Agent 가 실제 버전을 읽는다. */
export function buildFakeRelease(base, version, { withWebmaker = false } = {}) {
const dist = join(base, 'dist', version); mkdirSync(dist, { recursive: true }); const artifacts = {};
for (const m of ['hal', 'portal', 'agent', ...(withWebmaker ? ['webmaker', 'arimail'] : [])]) {
const src = join(base, 'dist-src', `${m}-${version}`); mkdirSync(join(src, m), { recursive: true }); writeFileSync(join(src, 'package.json'), JSON.stringify({ name: `ariweb-${m}`, version })); writeFileSync(join(src, m, 'index.mjs'), `// e2e fake ${m} ${version}\n`);
const file = `${m}-${version}.tar.gz`; tarGz(src, join(dist, file));
artifacts[m] = { file, sha256: createHash('sha256').update(readFileSync(join(dist, file))).digest('hex'), size: statSync(join(dist, file)).size };
}
const manifest = { version, mainCommit: 'e2e0000000000000000000000000000000000000', branch: 'e2e', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false } };
writeFileSync(join(dist, 'manifest.json'), JSON.stringify(manifest, null, 1)); return { dist, manifest: join(dist, 'manifest.json'), artifacts };
}
export async function bootEnv({ log = () => {} } = {}) {
const base = join(ROOT, 'test', '.tmp', `e2e-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`); mkdirSync(base, { recursive: true });
const [pHal, pPortal, pWm, pAm, pMail] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort()]);
const secrets = { service: rnd(32), sso: rnd(32), amSetup: 'st_' + rnd(20), amUsage: 'au_' + rnd(20), resend: 're_' + rnd(20), amSso: rnd(24) };
const halData = join(base, 'hal'); const portalData = join(base, 'portal'); mkdirSync(halData, { recursive: true }); mkdirSync(portalData, { recursive: true });
// 보관소: 서비스 키·SSO·발신 키
const hv = openVault(halData); hv.set('service:portal', secrets.service, { kind: 'service-key' }); hv.set('service:hal', secrets.service, { kind: 'service-key' });
const pv = openVault(portalData); pv.set('service:hal', secrets.service, { kind: 'service-key' }); pv.set('service:portal', secrets.service, { kind: 'service-key' }); pv.set('sso:webmaker', secrets.sso, { kind: 'shared-secret' }); pv.set('mailer:resend-key', secrets.resend, { kind: 'secret' });
// 가짜 서비스
const mail = await startFakeMail(pMail); const wm = await startFakeWebmaker(pWm, { secret: secrets.sso, workDir: join(base, 'wm-work') }); const am = await startFakeArimail(pAm, { setupToken: secrets.amSetup, usageKey: secrets.amUsage });
writeFileSync(join(portalData, 'config.json'), JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), updatedBy: 'e2e', values: { mailer: 'resend', resendBase: mail.url, mailVerifier: 'verify@external.e2e.localhost', mailVerifierInsecureTls: true } }));
const env = { base, ports: { hal: pHal, portal: pPortal, wm: pWm, am: pAm, mail: pMail }, secrets, mail, wm, am, agents: [], procs: [], log };
// ---- HAL
const halEnv = { ARIWEB_HAL_DATA: halData, PORT: String(pHal), HOST: '127.0.0.1', HAL_PUBLIC_URL: `http://127.0.0.1:${pHal}`, PORTAL_URL: `http://127.0.0.1:${pPortal}`, OPS_ALLOW_CIDRS: '127.0.0.1/32' };
const tokOut = runNode(['hal/index.mjs', 'setup-token'], halEnv); const setupToken = (tokOut.match(/:\s*([A-Za-z0-9_-]{20,})/) || [])[1];
const rel = buildFakeRelease(base, '0.1.1'); runNode(['hal/index.mjs', 'register-release', '--manifest', rel.manifest, '--approve', '--by', 'e2e'], halEnv);
const rel2 = buildFakeRelease(base, '0.1.2'); runNode(['hal/index.mjs', 'register-release', '--manifest', rel2.manifest, '--approve', '--by', 'e2e'], halEnv);
runNode(['hal/index.mjs', 'bootstrap-release', '--version', '0.1.1', '--commit', rel.manifest ? 'e2e0000000000000000000000000000000000000' : '', '--sha256', rel.artifacts.hal.sha256], halEnv);
env.hal = { url: `http://127.0.0.1:${pHal}`, data: halData, env: halEnv, proc: spawnNode('hal', ['hal/index.mjs'], halEnv), release: rel, release2: rel2, setupToken };
env.hal.restart = async ({ takeover = false } = {}) => { env.hal.proc.kill('SIGTERM'); await env.hal.proc.waitExit(4000); if (!env.hal.proc.exited) env.hal.proc.kill('SIGKILL'); env.hal.proc = spawnNode('hal', ['hal/index.mjs', ...(takeover ? ['--takeover'] : [])], halEnv); return waitFor(async () => { const r = await fetch(env.hal.url + '/api/health').then((x) => x.json()).catch(() => null); return r && r.ok ? r : null; }, { timeoutMs: 15000, intervalMs: 300 }); };
const h = await waitFor(async () => { const r = await fetch(env.hal.url + '/api/health').then((x) => x.json()).catch(() => null); return r && r.ok ? r : null; }, { timeoutMs: 20000, intervalMs: 300 });
if (!h || h.timedOut) throw new Error('HAL 기동 실패: ' + env.hal.proc.lines.slice(-5).join(' | '));
// 운영자 최초 설정·로그인
env.ops = makeClient(env.hal.url); env.opsLogin = 'e2eops'; env.opsPassword = 'ops-password-' + rnd(8);
const su = await env.ops.post('/ops/api/setup', { setupToken, login: env.opsLogin, password: env.opsPassword, displayName: 'E2E 운영자' }); if (su.status !== 200) throw new Error('운영자 설정 실패 ' + su.text.slice(0, 200));
env.loginOps = async () => { const r = await env.ops.post('/ops/api/login', { login: env.opsLogin, password: env.opsPassword }); if (r.status !== 200) throw new Error('운영자 로그인 실패 ' + r.text.slice(0, 200)); env.ops.setCsrf(r.json.csrf); return r; };
await env.loginOps();
/** 운영자 → 포털 내부 운영 API 중계(/ops/api/portal/* → 포털 /internal/ops/*) */
env.relay = (method, path, body) => env.ops.req(method, '/ops/api/portal' + path, body);
/** 하네스가 포털 내부 API 를 HAL 키로 직접 서명 호출(중계 결함 우회가 아니라 비교·검증용) */
env.portalSigned = signedClient(`http://127.0.0.1:${pPortal}`, 'hal', secrets.service, { 'X-Ops-Actor': 'e2e-ops' });
env.halSigned = signedClient(env.hal.url, 'portal', secrets.service);
// ---- Agents: control(부트스트랩) → web → backup → web2
env.servers = {};
env.servers.control = await startAgent(env, { name: 'ctl-1', role: 'control' });
env.servers.web = await startAgent(env, { name: 'web-1', role: 'web' });
env.servers.backup = await startAgent(env, { name: 'backup-1', role: 'backup' });
env.servers.web2 = await startAgent(env, { name: 'web-2', role: 'web' });
// heartbeat 로 용량이 채워질 때까지(pickServer 가 여유 용량을 본다)
await waitFor(async () => { const s = await env.ops.get('/ops/api/servers'); const ok = s.json?.servers?.filter((x) => x.status === 'ready' && x.capacity?.diskTotalBytes > 0).length; return ok >= 4 ? s : null; }, { timeoutMs: 15000, intervalMs: 500 });
// ---- 포털
const portalEnv = { ARIWEB_PORTAL_DATA: portalData, PORT: String(pPortal), HOST: '127.0.0.1', PUBLIC_URL: `http://127.0.0.1:${pPortal}`, HAL_URL: env.hal.url, WEBMAKER_URL: wm.url, WEBMAKER_INTERNAL_URL: wm.url, ARIMAIL_URL: am.url, ARIMAIL_INTERNAL_URL: am.url, MAIL_FROM: 'no-reply@e2e.localhost', ARIWEB_AUTOSEED_CATALOG: '1' };
env.portal = { url: `http://127.0.0.1:${pPortal}`, data: portalData, env: portalEnv, proc: spawnNode('portal', ['portal/index.mjs'], portalEnv) };
env.portal.restart = async () => { env.portal.proc.kill('SIGTERM'); await env.portal.proc.waitExit(4000); if (!env.portal.proc.exited) env.portal.proc.kill('SIGKILL'); env.portal.proc = spawnNode('portal', ['portal/index.mjs'], portalEnv); return waitFor(async () => { const r = await fetch(env.portal.url + '/login').catch(() => null); return r && r.status === 200 ? r : null; }, { timeoutMs: 15000, intervalMs: 300 }); };
const pr = await waitFor(async () => { const r = await fetch(env.portal.url + '/login').catch(() => null); return r && r.status === 200 ? r : null; }, { timeoutMs: 20000, intervalMs: 300 });
if (!pr || pr.timedOut) throw new Error('포털 기동 실패: ' + env.portal.proc.lines.slice(-8).join(' | '));
// AriMail 서버 등록(운영자 중계) — 비밀은 보관소로만
const ms = await env.relay('POST', '/mail/servers', { name: 'e2e AriMail', baseUrl: am.url, internalUrl: am.url, setupUrl: am.url, mailHost: '127.0.0.1', isDefault: true, secrets: { 'setup-token': secrets.amSetup, 'usage-key': secrets.amUsage, 'sso-secret': secrets.amSso } });
env.mailServerRegistered = ms.status === 200; env.mailServerResponse = { status: ms.status, body: ms.json };
env.customer = () => makeClient(env.portal.url);
env.stop = async () => { for (const a of env.agents) a.proc.kill('SIGKILL'); env.portal.proc.kill('SIGTERM'); env.hal.proc.kill('SIGTERM'); await sleep(600); env.portal.proc.kill('SIGKILL'); env.hal.proc.kill('SIGKILL'); await Promise.all([mail.close(), wm.close(), am.close()]); if (!process.env.ARIWEB_E2E_KEEP) { try { rmSync(base, { recursive: true, force: true }); } catch (_) {} } };
return env;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* test/e2e/fake-arimail.mjs — AriMail 의 포털 연동 면만 흉내 내는 HTTP 서버(설정 API·관리자 API·사용량 API·건강).
*
* portal/mail/arimail.mjs 어댑터가 부르는 끝점과 응답 모양을 따른다(test/mail.test.mjs 의 가짜 서버를 확장).
* - 설정 API(X-Setup-Token): /api/setup/domains(GET/POST), /api/setup/domains/:d(PATCH/DELETE), /api/setup/config(GET/PUT, ?domain=), /api/setup/plan, /api/setup/plan/run, /api/setup/dns/probe, /api/setup/check
* - 관리자 API(X-Mail-Scope: admin + mg_admin 쿠키): /api/login, /api/admin/users(GET/POST), /api/admin/users/:id(PATCH/DELETE), /api/admin/config(GET/PATCH), /api/admin/status, /api/admin/rates
* - 사용량 API(Bearer 키): /api/usage, /api/usage/:domain — 도메인별 보장량(LIMIT_*)과 저장·통수에서 경고 구간·제한 사유를 계산
* - SMTP/IMAP/POP3 는 없다(메일 왕복은 로컬에서 검증할 수 없음 — 보고서에 그렇게 적는다). 하네스는 state.usage 로 사용량을 조작한다.
*/
import http from 'node:http';
export async function startFakeArimail(port, { setupToken, usageKey }) {
const state = { domains: new Map(), configs: new Map(), users: new Map(), services: new Map(), usage: new Map(), sessions: new Map(), calls: [] };
const usersOf = (d) => { if (!state.users.has(d)) state.users.set(d, []); return state.users.get(d); };
const level = (x, l) => (!l ? 0 : x / l >= 1 ? 100 : x / l >= 0.95 ? 95 : x / l >= 0.9 ? 90 : x / l >= 0.8 ? 80 : x / l >= 0.7 ? 70 : 0);
const report = (d) => { const u = state.usage.get(d) || {}; const cfg = state.configs.get(d) || {}; const users = usersOf(d); const lim = { accounts: Number(cfg.LIMIT_ACCOUNTS || 0), totalBytes: Number(cfg.LIMIT_TOTAL_BYTES || 0), monthlyMessages: Number(cfg.LIMIT_MONTHLY_MESSAGES || 0) }; const storage = u.storageBytes || 0; const month = (u.sent || 0) + (u.received || 0); const levels = { storage: level(storage, lim.totalBytes), monthly: level(month, lim.monthlyMessages), accounts: level(users.length, lim.accounts) }; const reasons = []; if (levels.storage === 100) reasons.push('storage_full'); if (levels.monthly === 100) reasons.push('monthly_limit'); if (levels.accounts === 100) reasons.push('accounts_full'); const e = state.domains.get(d) || {};
return { domain: d, enabled: true, hosts: e.hosts || [`mail.${d}`], authMode: 'local', inbound: 'haraka', outbound: 'haraka', timezone: 'Asia/Seoul', ariweb: e.ariweb || null, operating: e.operating || 'active', accounts: { local: users.length, external: 0, total: users.length, limit: lim.accounts, percent: lim.accounts ? Math.round(users.length / lim.accounts * 100) : 0 }, storage: { bytes: storage, limit: lim.totalBytes, percent: lim.totalBytes ? Math.round(storage / lim.totalBytes * 100) : 0, largeFiles: { count: 0, bytes: 0, sent: 0 } }, messages: { total: u.messages || 0, inbound: u.received || 0, outbound: u.sent || 0, drafts: 0, failed: 0 }, traffic: { day: { sent: 0, received: 0, total: 0 }, week: { sent: 0, received: 0, total: 0 }, month: { sent: u.sent || 0, received: u.received || 0, total: month, sentBytes: 0, receivedBytes: 0 }, monthlyLimit: lim.monthlyMessages, monthlyPercent: lim.monthlyMessages ? Math.round(month / lim.monthlyMessages * 100) : 0 }, warnLevel: Math.max(levels.storage, levels.monthly, levels.accounts), warnLevels: levels, limitedReasons: reasons, monthReset: u.monthReset || null, countingRule: 'per_final_recipient', accountList: users.map((x) => ({ login: x.login, name: x.name, mailbox: `${x.login}@${d}`, role: x.role || 'user', source: 'local', status: x.status || 'active', messages: 0, bytes: x.bytes || 0, limit: x.quotaBytes || 0, percent: x.quotaBytes ? Math.round((x.bytes || 0) / x.quotaBytes * 100) : 0, lastLoginAt: null, sent: 0, received: 0 })), limits: { system: {}, admin: {} } }; };
const srv = http.createServer((req, res) => {
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
const url = new URL(req.url, 'http://x'); const p = url.pathname; const j = (code, o, h = {}) => { res.writeHead(code, { 'Content-Type': 'application/json', ...h }); res.end(JSON.stringify(o)); };
let b = {}; try { b = body ? JSON.parse(body) : {}; } catch (_) {}
const host = String(req.headers['x-forwarded-host'] || req.headers.host || ''); const domainOfHost = [...state.domains.values()].find((d) => (d.hosts || []).some((h) => String(h).split('/')[0] === host))?.domain || null;
state.calls.push({ m: req.method, p, host });
if (p === '/api/health') return j(200, { ok: true, version: '1.0.11-e2e-fake', domains: state.domains.size });
// ---- 설정 API
if (p.startsWith('/api/setup/')) {
if (req.headers['x-setup-token'] !== setupToken) return j(401, { ok: false, error: 'setup_token_invalid' });
const dq = url.searchParams.get('domain');
if (p === '/api/setup/domains' && req.method === 'GET') return j(200, { ok: true, domains: [...state.domains.values()] });
if (p === '/api/setup/domains' && req.method === 'POST') { const d = String(b.domain || '').toLowerCase(); if (!d) return j(422, { ok: false, error: 'domain_required' }); if (state.domains.has(d)) return j(409, { ok: false, error: 'domain_exists' }); const entry = { domain: d, enabled: true, hosts: b.hosts || [`mail.${d}`], mailbox: `${b.mailbox || 'admin'}@${d}`, auth: { mode: 'local' }, ariweb: b.ariweb || null, operating: b.operating || 'active', note: b.note || '' }; state.domains.set(d, entry); const pw = b.adminPassword || 'adminpasswd'; usersOf(d).push({ id: 'u_admin_' + d, login: b.mailbox || 'admin', name: '관리자', role: 'admin', password: pw, status: 'active' }); state.configs.set(d, {}); return j(201, { ok: true, domain: entry, initialAdmin: { login: b.mailbox || 'admin', password: pw } }); }
if (p.startsWith('/api/setup/domains/')) { const d = decodeURIComponent(p.split('/')[4] || ''); const e = state.domains.get(d); if (!e) return j(404, { ok: false, error: 'domain_not_found' }); if (req.method === 'PATCH') { Object.assign(e, b); return j(200, { ok: true, domain: e }); } if (req.method === 'DELETE') { state.domains.delete(d); state.users.delete(d); state.configs.delete(d); return j(200, { ok: true, removed: d }); } }
if (p === '/api/setup/config' && req.method === 'GET') { const d = dq || [...state.domains.keys()][0] || ''; const cfg = state.configs.get(d) || {}; return j(200, { ok: true, domain: d, values: Object.fromEntries(Object.entries(cfg).map(([k, v]) => [k, /SECRET|KEY|PASS/.test(k) ? '••••' : v])), meta: { host: `mail.${d}`, domain: d, dns: { records: [{ type: 'MX', name: d, value: `10 mail.${d}` }, { type: 'TXT', name: d, value: 'v=spf1 a mx -all' }, { type: 'TXT', name: `arimail._domainkey.${d}`, value: 'v=DKIM1; k=rsa; p=FAKEPUBLICKEY' }, { type: 'TXT', name: `_dmarc.${d}`, value: 'v=DMARC1; p=quarantine' }] }, inbound: 'haraka', outbound: 'haraka' } }); }
if (p === '/api/setup/config' && req.method === 'PUT') { const d = dq || ''; state.configs.set(d, { ...(state.configs.get(d) || {}), ...(b.values || {}) }); return j(200, { ok: true, saved: Object.keys(b.values || {}) }); }
if (p === '/api/setup/plan' && req.method === 'GET') return j(200, { ok: true, plan: { domain: dq, steps: [], records: [] } });
if (p === '/api/setup/plan/run') return j(200, { ok: true, log: [] });
if (p === '/api/setup/dns/probe') return j(200, { ok: true, probe: { mx: [], spf: null, dkim: null, dmarc: null } });
if (p === '/api/setup/check') return j(200, { ok: true, outboundStatus: { mode: 'haraka', provider: 'ok', checkedAt: new Date().toISOString() } });
return j(404, { ok: false, error: 'not_found' });
}
// ---- 사용량 API
if (p.startsWith('/api/usage')) { if ((req.headers.authorization || '') !== 'Bearer ' + usageKey) return j(401, { ok: false, error: 'unauthenticated' }); const d = decodeURIComponent(p.split('/')[3] || ''); if (d) { if (!state.domains.has(d)) return j(404, { ok: false, error: 'domain_not_found' }); return j(200, { ok: true, version: '1.0.11-e2e-fake', generatedAt: new Date().toISOString(), ...report(d) }); } return j(200, { ok: true, version: '1.0.11-e2e-fake', generatedAt: new Date().toISOString(), primary: [...state.domains.keys()][0] || null, totals: { domains: state.domains.size }, domains: [...state.domains.keys()].map(report) }); }
// ---- 로그인·관리자 API(도메인은 Host 로)
const d = domainOfHost; const admin = req.headers['x-mail-scope'] === 'admin';
if (p === '/api/login' && req.method === 'POST') { if (!d) return j(404, { ok: false, error: 'no_service_for_host' }); const u = usersOf(d).find((x) => x.login === b.login && x.password === b.password); if (!u) return j(401, { ok: false, error: 'invalid_credentials' }); if (admin && u.role !== 'admin') return j(403, { ok: false, error: 'admin_only' }); const tok = 'tok_' + Math.random().toString(36).slice(2); state.sessions.set(tok, { d, login: u.login, admin }); return j(200, { ok: true, user: { login: u.login, role: u.role } }, { 'Set-Cookie': `${admin ? 'mg_admin' : 'mg_session'}=${tok}; Path=/; HttpOnly` }); }
const cookie = String(req.headers.cookie || ''); const tok = (cookie.match(/mg_admin=([^;]+)/) || [])[1]; const sess = tok && state.sessions.get(tok);
if (p.startsWith('/api/admin/')) {
if (!admin || !sess || !sess.admin || sess.d !== d) return j(403, { ok: false, error: 'admin_login_required' });
const cfg = state.configs.get(d) || {}; const list = usersOf(d);
if (p === '/api/admin/users' && req.method === 'GET') return j(200, { ok: true, users: list.map((u) => ({ id: u.id, login: u.login, name: u.name, mailbox: `${u.login}@${d}`, role: u.role || 'user', status: u.status || 'active', quotaBytes: u.quotaBytes || 0, bytes: u.bytes || 0, services: u.services || { pop: true, imap: true } })), domain: d, accounts: { used: list.length, limit: Number(cfg.LIMIT_ACCOUNTS || 0) }, policy: { minLength: 6 }, services: state.services.get(d) || { pop: true, imap: true } });
if (p === '/api/admin/users' && req.method === 'POST') { if (list.some((u) => u.login === b.login)) return j(409, { ok: false, error: 'login_taken' }); if (Number(cfg.LIMIT_ACCOUNTS || 0) && list.length >= Number(cfg.LIMIT_ACCOUNTS)) return j(409, { ok: false, error: 'account_limit_reached' }); if (String(b.password || '').length < 6) return j(422, { ok: false, error: 'weak_password' }); const id = 'u_' + Math.random().toString(36).slice(2, 10); list.push({ id, login: b.login, name: b.name || '', role: b.role || 'user', password: b.password, status: b.status || 'active', quotaBytes: b.quotaBytes || 0, services: b.services }); return j(200, { ok: true, id }); }
if (p.startsWith('/api/admin/users/')) { const id = decodeURIComponent(p.split('/')[4]); const u = list.find((x) => x.id === id); if (!u) return j(404, { ok: false, error: 'not_found' }); if (req.method === 'PATCH') { Object.assign(u, b); return j(200, { ok: true }); } if (req.method === 'DELETE') { list.splice(list.indexOf(u), 1); return j(200, { ok: true }); } }
if (p === '/api/admin/config' && req.method === 'GET') return j(200, { ok: true, services: state.services.get(d) || { pop: true, imap: true }, largeFiles: { enabled: false, maxBytes: 0, maxDays: 0, maxDownloads: 0 }, systemLargeFiles: { bytes: 1024 ** 3, days: 30, downloads: 20 } });
if (p === '/api/admin/config' && req.method === 'PATCH') { if (b.services) state.services.set(d, { ...(state.services.get(d) || { pop: true, imap: true }), ...b.services }); return j(200, { ok: true, services: state.services.get(d) || { pop: true, imap: true } }); }
if (p === '/api/admin/status') return j(200, { ok: true, host: `mail.${d}`, tls: true, ports: { pop3s: 995, pop3: 110, imaps: 993, imap: 143, smtps: 465, submission: 587 }, services: state.services.get(d) || { pop: true, imap: true }, mail: { inbound: 'haraka', outbound: 'haraka', status: { provider: 'ok' } } });
if (p === '/api/admin/rates') return j(200, { ok: true, rates: { deliveryRate: 5, submissionRate: 10, buckets: [], submissions: [] }, queue: [], abuse: [] });
return j(404, { ok: false, error: 'not_found' });
}
if (p === '/api/mail') return j(200, { ok: true, messages: [] });
j(404, { ok: false, error: 'not_found' });
});
});
await new Promise((r) => srv.listen(port, '127.0.0.1', r));
return { url: `http://127.0.0.1:${port}`, state, close: () => new Promise((r) => srv.close(r)) };
}
+19
View File
@@ -0,0 +1,19 @@
/**
* test/e2e/fake-mail.mjs — Resend 호환 발신 API 흉내(포털 mailer 'resend' 모드 포획).
* 포털이 보내는 이메일(가입 확인·초대·필수 알림)을 메모리에 모아 하네스가 링크·토큰을 꺼내 쓴다. 실제 발송은 없다.
* POST /emails { from, to[], subject, text, html } → { id }
*/
import http from 'node:http';
export async function startFakeMail(port) {
const mails = [];
const srv = http.createServer((req, res) => {
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
const j = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
if (req.method === 'POST' && req.url === '/emails') { if (!/^Bearer /.test(req.headers.authorization || '')) return j(401, { name: 'unauthenticated' }); let m = {}; try { m = JSON.parse(body); } catch (_) {} const id = 'mail_' + (mails.length + 1); mails.push({ id, at: new Date().toISOString(), to: [].concat(m.to || []), subject: m.subject || '', text: m.text || '', html: m.html || '' }); return j(200, { id }); }
j(404, { name: 'not_found' });
});
});
await new Promise((r) => srv.listen(port, '127.0.0.1', r));
return { url: `http://127.0.0.1:${port}`, mails, /** 받는 사람·제목 조건으로 마지막 메일 찾기 */ last: (to, re = null) => [...mails].reverse().find((m) => (!to || m.to.includes(to)) && (!re || re.test(m.subject + '\n' + m.text))) || null, close: () => new Promise((r) => srv.close(r)) };
}
+62
View File
@@ -0,0 +1,62 @@
/**
* test/e2e/fake-webmaker.mjs — Webmaker 의 포털 연동 면(/api/internal/ariweb/*, /api/auth/sso)만 흉내 내는 HTTP 서버.
*
* 응답 모양은 modules/webmaker/server.js 의 handleAriwebInternal 과 portal/webmaker/adapter.mjs 가 기대하는 것을 따른다.
* - 서명 검증: lib/serviceauth(키 id 'portal', 비밀 = 포털 보관소 'sso:webmaker')
* - 프로젝트는 메모리. 저장본(versionList)은 시험 전용 끝점 POST /_test/projects/:id/version 으로 추가한다(빌더에서 저장한 것을 흉내).
* - export 는 index.html(표식 marker-<프로젝트>-v<n> 포함)을 담은 tar.gz 스트림 + X-Ariweb-Version/X-Ariweb-Commit 헤더.
* - /api/auth/sso?ticket= : HMAC 검증 뒤 302(세션 쿠키 흉내). 1회용 nonce.
*/
import http from 'node:http';
import { mkdirSync, writeFileSync, createReadStream, rmSync } from 'node:fs';
import { join } from 'node:path';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { verifyRequest } from '../../lib/serviceauth.mjs';
import { tarGz } from './util.mjs';
export async function startFakeWebmaker(port, { secret, workDir }) {
const projects = new Map(); let seq = 0; const calls = []; const usedNonces = new Set(); const credits = new Map();
mkdirSync(workDir, { recursive: true });
const summary = (p) => ({ id: p.id, title: p.title, ownerId: p.ownerId, status: p.status, mode: p.mode, stage: p.stage, rev: p.rev, createdAt: p.createdAt, updatedAt: p.updatedAt, lastChange: p.versionList.at(-1) ? { no: p.versionList.at(-1).no, kind: p.versionList.at(-1).kind, at: p.versionList.at(-1).at, message: p.versionList.at(-1).message } : null, versions: p.versionList.length, link: p.link, hasContent: p.versionList.length > 0 });
const full = (p) => ({ ...summary(p), versionList: p.versionList.map((v) => ({ no: v.no, kind: v.kind, message: v.message, at: v.at, commit: v.commit, files: 1 })), git: { provider: 'gitea', enabled: true, remote: 'webmaker/sites', mainBranch: `sites/${p.id}/main`, mainUrl: `http://127.0.0.1:${port}/gitea/webmaker/sites/src/branch/sites/${p.id}/main` }, liveUrl: null, deployState: null, domains: [] });
const srv = http.createServer((req, res) => {
let raw = ''; req.on('data', (c) => (raw += c)); req.on('end', async () => {
const url = new URL(req.url, 'http://x'); const p = url.pathname; const j = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
calls.push({ m: req.method, p });
// 시험 전용: 저장본 추가(빌더에서 저장한 것을 흉내) — 서명 없음(127.0.0.1 전용)
if (p.startsWith('/_test/projects/') && req.method === 'POST') { const id = p.split('/')[3]; const pr = projects.get(id); if (!pr) return j(404, { error: 'no project' }); const no = pr.versionList.length + 1; pr.versionList.push({ no, kind: no === 1 ? 'create' : 'update', message: `저장본 ${no}`, at: new Date().toISOString(), commit: `c${no}-${id.slice(0, 6)}` }); pr.rev++; pr.updatedAt = new Date().toISOString(); return j(200, { ok: true, no }); }
if (p === '/_test/state') return j(200, { projects: [...projects.values()].map(full), calls: calls.length });
if (p === '/api/auth/sso') {
const t = String(url.searchParams.get('ticket') || ''); const [payload, sig] = t.split('.'); if (!payload || !sig) return j(400, { error: 'bad_ticket' });
const want = createHmac('sha256', secret).update(payload).digest('base64url'); const a = Buffer.from(want), b = Buffer.from(sig); if (a.length !== b.length || !timingSafeEqual(a, b)) return j(401, { error: 'bad_signature' });
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return j(400, { error: 'bad_payload' }); }
if (d.exp < Date.now()) return j(401, { error: 'expired' }); if (usedNonces.has(d.nonce)) return j(401, { error: 'ticket_used' }); usedNonces.add(d.nonce);
res.writeHead(302, { Location: `/?project=${encodeURIComponent(d.projectId || '')}&site=${encodeURIComponent(d.siteId || '')}`, 'Set-Cookie': `wh_session=fake-${d.login}; Path=/; HttpOnly` }); return res.end();
}
if (!p.startsWith('/api/internal/ariweb/')) return j(404, { error: 'not_found' });
const v = verifyRequest({ method: req.method, path: p + url.search, headers: req.headers, rawBody: raw }, (id) => (id === 'portal' ? secret : null)); if (!v.ok) return j(401, { error: 'service_auth_failed', reason: v.reason });
let b = {}; try { b = raw ? JSON.parse(raw) : {}; } catch (_) {}
const [kind, id, action] = p.slice('/api/internal/ariweb/'.length).split('/').filter(Boolean).map(decodeURIComponent);
if (kind === 'health') return j(200, { ok: true, version: '1.7.3-fake', product: 'AI Webmaker(fake)', portalUrl: null });
if (kind === 'credits' && id) { const c = credits.get(id) || { balance: 12000, used: 3000, granted: 15000 }; return j(200, { ok: true, credit: { userId: id, ...c, updatedAt: new Date().toISOString(), entries: 2 }, unit: '원' }); }
if (kind !== 'projects') return j(404, { error: 'not_found' });
if (!id && req.method === 'GET') { const owner = String(url.searchParams.get('owner') || '').toLowerCase(); return j(200, { ok: true, projects: [...projects.values()].filter((x) => x.ownerId === owner).map(summary) }); }
if (!id && req.method === 'POST') { const pid = (++seq).toString(16).padStart(32, '0'); const pr = { id: pid, title: String(b.title || ''), ownerId: String(b.owner || '').toLowerCase(), status: 'idle', mode: b.mode || 'quick', stage: b.mode === 'ai' ? 'input' : null, rev: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), versionList: [], link: b.link && b.link.siteId ? { ...b.link, linkedAt: new Date().toISOString() } : null }; projects.set(pid, pr); return j(200, { ok: true, project: summary(pr) }); }
const pr = projects.get(id); if (!pr) return j(404, { error: '프로젝트가 없습니다' });
if (!action && req.method === 'GET') return j(200, { ok: true, project: full(pr) });
if (action === 'link' && req.method === 'POST') { pr.link = { ...b, linkedAt: new Date().toISOString() }; return j(200, { ok: true, link: pr.link }); }
if (action === 'link' && req.method === 'DELETE') { const had = !!pr.link; pr.link = null; return j(200, { ok: true, removed: had }); }
if (action === 'source' && req.method === 'GET') return j(200, { ok: true, source: { ...full(pr).git, repoBytes: 2048, siteBytes: 4096, uploadBytes: 0, lfsBytes: 0, lfsSupported: false, changes: pr.versionList.map((x) => ({ ...x, pushed: true, branch: `sites/${pr.id}/changes/${String(x.no).padStart(4, '0')}-${x.kind}`, url: null })), remoteStatus: { ok: true } } });
if (action === 'export' && req.method === 'GET') {
const vno = url.searchParams.get('version'); const ch = vno ? pr.versionList.find((x) => String(x.no) === String(vno)) : pr.versionList.at(-1); if (vno && !ch) return j(404, { error: '해당 저장본이 없습니다' });
const dir = join(workDir, `export-${pr.id}-${Date.now()}`); mkdirSync(dir, { recursive: true }); const n = ch ? ch.no : 0;
writeFileSync(join(dir, 'index.html'), `<!doctype html><title>${pr.title}</title><h1>${pr.title}</h1><p>marker-${pr.id.slice(0, 8)}-v${n}</p>`); writeFileSync(join(dir, 'ariweb.json'), JSON.stringify({ runtime: 'static' }));
const file = join(workDir, `export-${pr.id}-${Date.now()}.tar.gz`); tarGz(dir, file); rmSync(dir, { recursive: true, force: true });
res.writeHead(200, { 'Content-Type': 'application/gzip', 'X-Ariweb-Version': vno || 'current', 'X-Ariweb-Commit': ch ? ch.commit : '' }); createReadStream(file).pipe(res); return;
}
j(404, { error: 'not_found' });
});
});
await new Promise((r) => srv.listen(port, '127.0.0.1', r));
return { url: `http://127.0.0.1:${port}`, projects, calls, addVersion: async (id) => { const r = await fetch(`http://127.0.0.1:${port}/_test/projects/${id}/version`, { method: 'POST' }); return r.json(); }, close: () => new Promise((r) => srv.close(r)) };
}
+42
View File
@@ -0,0 +1,42 @@
/** 흐름 1·2: 가입 → 확인 메일 → 이메일 확인 → 개인 그룹 → 팀 그룹·초대·수락. 이후 흐름이 쓰는 고객 클라이언트(state.alice / state.bob)를 만든다. */
import { steps } from '../report.mjs';
import { waitFor } from '../util.mjs';
export const BUDGET_MS = 40000;
/** 가입 + 이메일 확인 + CSRF 까지 끝낸 고객 클라이언트 */
export async function signupVerified(env, email, name) {
const c = env.customer();
const r = await c.post('/api/auth/signup', { email, displayName: name, password: 'pw-' + email.split('@')[0] + '-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 200) throw new Error(`가입 실패 ${r.status} ${r.text.slice(0, 200)}`);
const cs0 = await c.get('/api/auth/csrf'); c.setCsrf(cs0.json.csrf); // 가입 직후 세션의 CSRF(변경 API 에 필요)
const m = await waitFor(() => env.mail.last(email, /token=/), { timeoutMs: 8000, intervalMs: 200 }); const token = m && m.text ? (m.text.match(/token=([A-Za-z0-9_-]+)/) || [])[1] : null; if (!token) throw new Error('확인 메일 토큰 없음');
const v = await c.post('/api/auth/verify-email', { token }); if (v.status !== 200) throw new Error(`이메일 확인 실패 ${v.text.slice(0, 200)}`);
const cs = await c.get('/api/auth/csrf'); c.setCsrf(cs.json.csrf); c.email = email; return c;
}
export async function run({ env, report, state }) {
const s = steps();
try {
const email = 'alice@e2e.localhost';
const alice = await s.run('가입(이메일·표시 이름·비밀번호·필수 동의) → 세션 발급', async () => { const c = env.customer(); const r = await c.post('/api/auth/signup', { email, displayName: '앨리스', password: 'pw-alice-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return { status: r.status, hasSession: !!c.cookie('aw_session') }; }) && env.customer();
const r0 = await alice.post('/api/auth/login', { email, password: 'pw-alice-e2e-1' }); const cs0 = await alice.get('/api/auth/csrf'); alice.setCsrf(cs0.json.csrf); alice.email = email;
await s.run('확인 전 유료·운영 기능 차단(그룹 생성 403 email_unverified)', async () => { const r = await alice.post('/api/groups', { name: '팀' }); if (r.status !== 403 || r.json.error !== 'email_unverified') throw new Error(`${r.status} ${r.json?.error}`); return r.json.error; });
const token = await s.run('확인 메일 수신(발신 포획)에서 토큰 추출', async () => { const m = await waitFor(() => env.mail.last(email, /token=/), { timeoutMs: 8000, intervalMs: 200 }); const t = m && m.text ? (m.text.match(/token=([A-Za-z0-9_-]+)/) || [])[1] : null; if (!t) throw new Error('메일 없음'); return { subject: m.subject }; }) && (env.mail.last(email, /token=/).text.match(/token=([A-Za-z0-9_-]+)/) || [])[1];
await s.run('이메일 확인(1회용) → 재사용 거부', async () => { const a = await alice.post('/api/auth/verify-email', { token }); const b = await alice.post('/api/auth/verify-email', { token }); if (a.status !== 200 || b.json?.error !== 'token_used') throw new Error(`${a.status}/${b.json?.error}`); return { first: a.status, second: b.json.error }; });
const me = await s.run('내 정보: 이메일 확인됨 · 개인 그룹 · owner · 별칭 user-01', async () => { const r = await alice.get('/api/auth/me'); const g = await alice.get('/api/group'); if (!r.json.user.email_verified_at || r.json.group.kind !== 'personal') throw new Error(JSON.stringify(r.json).slice(0, 200)); return { verified: true, groupKind: r.json.group.kind, role: r.json.role, alias: g.json.members?.[0]?.alias }; });
await s.run('중복 가입은 존재를 드러내지 않는 안내(409 signup_unavailable)', async () => { const r = await env.customer().post('/api/auth/signup', { email: 'ALICE@e2e.localhost', displayName: 'x', password: 'pw-dup-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 409) throw new Error(String(r.status)); return r.json.error; });
await s.run('로그인 기기 목록 · 다른 세션 종료', async () => { const other = env.customer(); await other.post('/api/auth/login', { email, password: 'pw-alice-e2e-1' }); const l = await alice.get('/api/account/sessions'); const n = l.json.sessions.length; const rv = await alice.post('/api/account/sessions/revoke-others'); const me2 = await other.get('/api/auth/me'); if (!(n >= 2) || me2.json.user) throw new Error('세션 종료 안 됨'); return { sessionsBefore: n, revoked: rv.json.revoked }; });
state.alice = alice; state.aliceEmail = email;
report.flow(1, 'verified_local', { steps: s.list, note: '가입→확인 메일(포획)→1회용 토큰 확인→개인 그룹·별칭→중복 가입 안내→기기 목록·세션 종료', evidence: { me } });
} catch (e) { report.flow(1, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '가입·이메일 확인 흐름 실패', file: 'portal/auth/api.mjs', expected: '가입→확인→개인 그룹', actual: e.message, repro: '10-identity.mjs 흐름 1', flows: [1] }); return; }
const s2 = steps();
try {
const alice = state.alice;
const teamId = await s2.run('팀 그룹 만들기 → 현재 그룹 전환', async () => { const r = await alice.post('/api/groups', { name: 'E2E 팀' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const me = await alice.get('/api/auth/me'); if (me.json.group.id !== r.json.groupId) throw new Error('전환 안 됨'); return r.json.groupId; });
const bob = await signupVerified(env, 'bob@e2e.localhost', '밥');
const link = await s2.run('이메일 초대(7일) → 초대 메일 링크', async () => { const r = await alice.post('/api/group/invites', { email: 'bob@e2e.localhost', role: 'member' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const m = await waitFor(() => env.mail.last('bob@e2e.localhost', /invite\//), { timeoutMs: 8000, intervalMs: 200 }); const t = m && m.text ? (m.text.match(/invite\/([A-Za-z0-9_-]+)/) || [])[1] : null; if (!t) throw new Error('초대 메일 없음'); return t; });
await s2.run('초대 수락(1회용) → 별칭 user-02 · 역할 member', async () => { const r = await bob.post('/api/invites/accept', { token: link }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const g = await bob.get('/api/group'); const again = await bob.post('/api/invites/accept', { token: link }); if (g.json.group.id !== teamId || again.status === 200) throw new Error('수락 상태 이상'); return { alias: g.json.me.alias, role: g.json.me.role, reuse: again.json?.error }; });
await s2.run('권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할', async () => { const r = await bob.patch('/api/group', { name: 'x' }); if (r.status !== 403) throw new Error(String(r.status)); return r.json.detail; });
await s2.run('그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음', async () => { const personal = (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal'); const a = await bob.get('/api/dashboard'); await bob.post('/api/groups/switch', { groupId: personal.id }); const b = await bob.get('/api/dashboard'); await bob.post('/api/groups/switch', { groupId: teamId }); return { team: a.json.summary?.sites, personal: b.json.summary?.sites }; });
state.bob = bob; state.teamId = teamId; await alice.post('/api/groups/switch', { groupId: teamId });
report.flow(2, 'verified_local', { steps: s2.list, note: '팀 그룹 생성·초대 메일·1회용 수락·별칭 user-02·역할 거부·그룹 전환 격리' });
} catch (e) { report.flow(2, 'failed', { steps: s2.list, note: e.message }); report.bug({ title: '그룹·초대 흐름 실패', file: 'portal/groups/api.mjs', expected: '팀 그룹·초대·수락', actual: e.message, repro: '10-identity.mjs 흐름 2', flows: [2] }); }
}
+33
View File
@@ -0,0 +1,33 @@
/** 흐름 3·24: 상품 목록 → 주문(멱등) → 운영자 입금 확인·승인(HAL 중계) → 권리 활성화 → 고객 알림·감사·운영자 화면 일치. */
import { steps } from '../report.mjs';
export const BUDGET_MS = 40000;
/** 주문을 넣고 운영자 중계로 입금 확인·승인까지 끝낸다(다른 흐름이 재사용). */
export async function orderAndApprove(env, client, { productId = 'team', items = [], key }) {
const prods = await client.get('/api/commerce/products'); const pv = prods.json.products.find((p) => p.productId === productId || p.product_id === productId || p.id === productId); if (!pv) throw new Error(`상품 없음: ${productId} (${prods.status})`);
const o = await client.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: key, items }); if (o.status !== 200) throw new Error(`주문 실패 ${o.status} ${o.text.slice(0, 200)}`);
const paid = await env.relay('POST', `/commerce/orders/${o.json.order.id}/paid`, { ref: 'e2e 입금' }); if (paid.status !== 200) throw new Error(`입금 확인 실패 ${paid.status} ${paid.text.slice(0, 200)}`);
const ap = await env.relay('POST', `/commerce/orders/${o.json.order.id}/approve`, {}); if (ap.status !== 200) throw new Error(`승인 실패 ${ap.status} ${ap.text.slice(0, 200)}`);
return { order: o.json.order, pv, approve: ap.json };
}
export async function run({ env, report, state }) {
const s = steps(); const alice = state.alice; if (!alice) { report.flow(3, 'partial', { note: '고객 세션 없음', blockedBy: 1 }); return; }
try {
const pv = await s.run('상품 목록(공개 버전만·가격·한도·미확정 정책 표시)', async () => { const r = await alice.get('/api/commerce/products'); if (r.status !== 200 || !r.json.products.length) throw new Error(r.text.slice(0, 200)); const team = r.json.products.find((p) => p.productId === 'team'); return { count: r.json.products.length, team: { id: team.id, price: team.price, sites: team.limits?.sites }, deferred: team.deferred?.length, paymentMethods: r.json.paymentMethods?.map((m) => m.id) }; }) && (await alice.get('/api/commerce/products')).json.products.find((p) => p.productId === 'team');
const order = await s.run('주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음', async () => { const a = await alice.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'e2e-order-1', items: [{ optionId: 'standalone_mail', qty: 1 }] }); if (a.status !== 200) throw new Error(a.text.slice(0, 300)); const b = await alice.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'e2e-order-1' }); if (b.json.created !== false) throw new Error('멱등 실패'); return { orderNo: a.json.order.order_no, status: a.json.order.status, total: a.json.order.total, dueAt: a.json.order.due_at, hasInstructions: !!a.json.order.payment_instructions }; }) && (await alice.get('/api/commerce/orders')).json.orders[0];
await s.run('입금 확인 전 승인 거부(409 payment_not_confirmed)', async () => { const r = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); if (r.status !== 409) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); return r.json.error; });
await s.run('운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시', async () => { const r = await env.relay('POST', `/commerce/orders/${order.id}/paid`, { ref: 'e2e 입금 확인' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const l = await env.relay('GET', '/commerce/orders?status=awaiting_approval'); const o = l.json.orders.find((x) => x.id === order.id); return { status: r.json.order.status, delta: o?.entitlementDelta?.map((d) => d.resource) }; });
const ap = await s.run('운영자 승인 → 권리 활성화(재승인은 중복 없음)', async () => { const a = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); if (a.status !== 200) throw new Error(a.text.slice(0, 200)); const b = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); return { granted: a.json.granted, already: b.json.already }; });
const bill = await s.run('고객 결제 화면: 활성 상품·권리·다음 청구일', async () => { const r = await alice.get('/api/commerce/billing'); const sites = r.json.entitlements?.find((e) => e.resource === 'sites'); if (!sites || sites.purchased < 1) throw new Error(JSON.stringify(r.json).slice(0, 300)); return { sites: sites.purchased, subs: r.json.subscriptions?.length, nextBilling: r.json.subscriptions?.[0]?.next_billing_at }; });
state.order = order; state.teamProduct = pv;
report.flow(3, 'verified_local', { steps: s.list, note: '상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면', evidence: { ap, bill } });
} catch (e) { report.flow(3, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '주문·승인·권리 활성화 흐름 실패', file: 'portal/commerce/api.mjs', route: '/api/commerce/orders, /internal/ops/commerce/orders/:id/{paid,approve}', expected: '승인 뒤 권리 활성화', actual: e.message, repro: '20-commerce.mjs', flows: [3] }); return; }
const s2 = steps();
try {
const alice = state.alice; const order = state.order;
const n = await s2.run('고객 알림 센터에 승인 알림', async () => { const r = await alice.get('/api/notifications'); const x = (r.json.notifications || r.json.items || []).find((y) => /승인/.test(y.title) && (y.subject_id === order.id || /주문/.test(y.title))); if (!x) throw new Error('알림 없음: ' + JSON.stringify(r.json).slice(0, 200)); return { title: x.title, category: x.category, link: x.link }; });
const a = await s2.run('감사 기록: order.approve 는 삭제 불가(immutable)', async () => { const r = await alice.get('/api/audit?action=order.approve'); const x = (r.json.items || [])[0]; if (!x || !x.immutable) throw new Error(JSON.stringify(r.json).slice(0, 200)); return { action: x.action, immutable: x.immutable, actor: x.actor_type }; });
const same = await s2.run('운영자 화면과 고객 주문 화면의 상태 일치', async () => { const o = await env.relay('GET', `/commerce/orders/${order.id}`); const c = await alice.get(`/api/commerce/orders/${order.id}`); if (o.json.order.status !== c.json.order.status) throw new Error(`${o.json.order.status} vs ${c.json.order.status}`); return { status: c.json.order.status, approvedAt: c.json.order.approved_at }; });
report.flow(24, 'verified_local', { steps: s2.list, note: '승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준)', evidence: { n, a, same } });
} catch (e) { report.flow(24, 'partial', { steps: s2.list, note: e.message }); }
}
+85
View File
@@ -0,0 +1,85 @@
/** 흐름 4·5·6·7·8·9·23·27: 초안 사이트 → Webmaker SSO·저장본 → 관리형 배포(실제 HAL·Agent) → 도메인 연결 → 외부 확인 → 통계 → 실패 작업 재시도·되돌리기 → 마지막 사이트 수 동시 요청. */
import { steps } from '../report.mjs';
import { waitFor, sleep } from '../util.mjs';
import { signupVerified } from './10-identity.mjs';
import { orderAndApprove } from './20-commerce.mjs';
export const BUDGET_MS = 150000;
/** 포털 작업이 끝날 때까지(최대 ms) 사이트 작업 조회 */
export async function waitSiteJob(client, siteId, jobId, ms = 60000) { return waitFor(async () => { const r = await client.get(`/api/sites/${siteId}/jobs/${jobId}`); const j = r.json?.job || r.json; return j && ['done', 'failed', 'cancelled', 'reverted', 'needs_user', 'awaiting_external'].includes(j.status) ? j : null; }, { timeoutMs: ms, intervalMs: 1000, label: 'site job' }); }
/** 도메인 연결 작업 취소(결함: 외부 대기 중 5초마다 HAL domain.probe 재제출 — 뒤 흐름의 HAL 큐 보호용) */
export async function cancelDomainJob(client, domainId) { const x = await client.get(`/api/domains/${domainId}`); const jid = x.json?.job?.id; if (!jid) return null; const r = await client.post(`/api/domains/${domainId}/jobs/${jid}/cancel`); return r.status; }
export async function createSiteWithVersion(env, client, name, { deployMode = 'managed' } = {}) {
const r = await client.post('/api/sites', { name, purpose: 'e2e', projectMode: 'new', buildMode: 'manual', deployMode }); if (r.status !== 200) throw new Error(`사이트 생성 실패 ${r.status} ${r.text.slice(0, 200)}`);
const site = r.json.site; await env.wm.addVersion(site.webmaker_project_id); return site;
}
export async function run({ env, report, state, log }) {
const alice = state.alice; if (!alice) { for (const n of [4, 5, 6, 7, 8, 9, 23, 27]) report.flow(n, 'partial', { note: '고객 세션 없음', blockedBy: 1 }); return; }
let site = null;
// ---- 4 초안 사이트
{ const s = steps(); try {
site = await s.run('사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내', async () => { const r = await alice.post('/api/sites', { name: 'E2E 사이트', purpose: '검증', projectMode: 'new', buildMode: 'manual', deployMode: 'managed', lang: 'ko', timezone: 'Asia/Seoul' }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); if (r.json.site.lifecycle !== 'draft' || r.json.site.site_count_state !== 'none') throw new Error(JSON.stringify(r.json.site).slice(0, 200)); state._created = r.json.site; return { id: r.json.site.id, lifecycle: r.json.site.lifecycle, projectId: r.json.site.webmaker_project_id, nextSteps: r.json.nextSteps?.map((x) => x.id) }; }) && state._created;
await s.run('사이트 목록 필터(초안)·사용량 요약에 예약 0', async () => { const l = await alice.get('/api/sites?filter=draft'); const u = await alice.get('/api/usage'); const sites = u.json.resources?.find((x) => x.resource === 'sites'); return { drafts: l.json.sites.length, sitesReserved: sites?.reserved, sitesAllocated: sites?.allocated }; });
state.site = site; report.flow(4, 'verified_local', { steps: s.list, note: '초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터' });
} catch (e) { report.flow(4, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '초안 사이트 생성 실패', file: 'portal/sites/api.mjs', route: 'POST /api/sites', expected: 'draft site', actual: e.message, repro: '30-sites.mjs', flows: [4] }); } }
if (!site) { for (const n of [5, 6, 7, 8, 9, 23]) report.flow(n, 'partial', { note: '사이트 없음', blockedBy: 4 }); return; }
// ---- 5 Webmaker 제작(SSO 진입 + 저장본)
{ const s = steps(); try {
const sso = await s.run('제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)', async () => { const r = await alice.get(`/sso/webmaker?site=${site.id}`); if (r.status !== 302) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const loc = r.headers.location; const w = await fetch(loc, { redirect: 'manual' }); const again = await fetch(loc, { redirect: 'manual' }); if (w.status !== 302) throw new Error(`webmaker ${w.status}`); return { portalRedirect: loc.replace(/ticket=.*/, 'ticket=…'), webmaker: w.status, reuse: again.status }; });
const ver = await s.run('Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과', async () => { await env.wm.addVersion(site.webmaker_project_id); const v = await alice.get(`/api/sites/${site.id}/versions`); const pc = await alice.get(`/api/sites/${site.id}/precheck`); if (!pc.json.precheck?.ok) throw new Error('precheck: ' + JSON.stringify(pc.json.precheck?.items?.filter((i) => i.state === 'block')).slice(0, 300)); return { versions: (v.json.versions || v.json.versionList || []).length, precheckOk: pc.json.precheck.ok }; });
report.flow(5, 'verified_local', { steps: s.list, note: 'SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음)', evidence: { sso, ver } });
} catch (e) { report.flow(5, 'partial', { steps: s.list, note: e.message }); } }
// ---- 6 관리형 배포 (실제 HAL → 실제 Agent)
let deployJob = null; { const s = steps(); try {
const d = await s.run('배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나', async () => { const a = await alice.post(`/api/sites/${site.id}/deploy`, { versionRef: 'current' }); if (a.status !== 200) throw new Error(a.text.slice(0, 300)); const b = await alice.post(`/api/sites/${site.id}/deploy`, { versionRef: 'current' }); const st = (await alice.get(`/api/sites/${site.id}`)).json.site; return { jobId: a.json.job.id, steps: a.json.job.steps.length, again: b.json.created, siteCount: st.site_count_state, server: st.server_id }; });
deployJob = await s.run('HAL·Agent 를 거쳐 작업 완료 대기(최대 40초)', async () => { const j = await waitSiteJob(alice, site.id, d.jobId, 40000); const halAll = (await env.ops.get('/ops/api/jobs?kind=site.deploy&limit=20')).json?.jobs || []; const halJobs = { count: halAll.length, first: halAll[0] ? [halAll[0].status, (halAll[0].error || '').slice(0, 140)] : null }; const cur = (await alice.get(`/api/sites/${site.id}/jobs/${d.jobId}`)).json; const cj = cur.job || cur; if (!j || j.timedOut) throw new Error(`시간 초과 — 포털 작업 ${cj.status}(attempt ${cj.attempt}, ${cj.waitReason || cj.wait_reason || ''}) · HAL site.deploy 작업 ${halJobs.count}개(최근: ${JSON.stringify(halJobs.first)})`); if (j.status !== 'done') throw new Error(`${j.status}: ${j.error} (${j.errorCode}) 단계=${JSON.stringify((j.steps || []).map((x) => x.id + ':' + x.state))}`); return { status: j.status, verify: j.result?.verify, siteUrl: j.result?.siteUrl, steps: j.steps.map((x) => x.id + ':' + x.state) }; });
const after = await s.run('배포 뒤 사이트 상태·버전·건강', async () => { const st = (await alice.get(`/api/sites/${site.id}`)).json.site; const ag = env.servers.web; return { lifecycle: st.lifecycle, version: st.current_version, verify: st.deploy_verify, health: st.health, agentHasSite: (await import('node:fs')).existsSync(`${ag.roots.sites}/${site.id}/current`) || (await import('node:fs')).existsSync(`${env.servers.web2.roots.sites}/${site.id}/current`) }; });
report.flow(6, 'verified_local', { steps: s.list, note: '배포 요청→예약→HAL 작업→Agent(stub) 배포→도메인 설정 필요 상태(대표 도메인 없음)', evidence: { d, deployJob, after } });
} catch (e) {
// 포털 경로가 막힌 경우: HAL·Agent 쪽 배포 경로(로컬 저장본) 만 따로 확인해 결함 위치를 좁힌다
await s.run('[HAL 직접] 같은 Agent 에 로컬 저장본으로 site.deploy → 성공하면 결함은 포털→Agent 번들 전달 구간', async () => { const ag = env.servers.web; const { mkdirSync, writeFileSync, existsSync } = await import('node:fs'); const bdir = `${ag.dir}/bundle-direct`; mkdirSync(bdir, { recursive: true }); writeFileSync(`${bdir}/index.html`, '<h1>direct marker-direct</h1>'); const r = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'site.deploy', target: ag.serverId, payload: { siteId: 'direct_e2e', versionRef: 'v1', bundlePath: bdir, domains: [], expectMarker: 'marker-direct' }, idempotencyKey: 'e2e-direct-deploy' }); const j = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${r.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: j?.status, error: j?.error, layout: existsSync(`${ag.roots.sites}/direct_e2e/current`), externalCheck: j?.result?.externalCheck }; }, { soft: true });
const halErr = (await env.ops.get('/ops/api/jobs?kind=site.deploy&limit=3')).json?.jobs?.map((x) => x.error).filter(Boolean)[0] || '';
if (/Failed to parse URL|내려받기 실패/.test(halErr)) report.bug({ title: 'Agent 가 포털 저장본(bundleUrl, 절대 URL)을 내려받지 못함(HAL 주소를 앞에 덧붙임)', file: 'agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle)', route: 'HAL site.deploy → Agent fetch 단계', expected: 'bundleUrl 이 절대 URL 이면 그대로 내려받는다(서명은 HAL 전용이므로 포털 번들 URL 에는 붙이지 않음)', actual: halErr, repro: '포털 배포 → HAL site.deploy → Agent: "Failed to parse URL from http://<hal>http://<portal>/internal/bundles/…"', flows: [6, 8, 9, 13, 14, 15, 23] });
const cj0 = (await alice.get(`/api/sites/${site.id}/jobs/${s.list[0]?.detail?.jobId || ''}`)).json; const pj = cj0 && (cj0.job || cj0);
if (pj && pj.status === 'queued' && pj.attempt > 2) report.bug({ title: '포털 배포 작업이 실패한 HAL 작업을 실패로 보지 않고 5초마다 새 HAL 작업을 무한 재제출(배포가 영원히 "진행 중")', file: 'portal/sites/service.mjs (halJobFor: prev HAL 작업이 failed/cancelled 면 fresh 가 아니어도 새 작업을 만든다 → awaitHal 이 실패를 보지 못함)', route: 'POST /api/sites/:id/deploy', expected: 'HAL 작업이 failed 면 포털 작업도 failed(이전 버전 유지·재시도 버튼), 새 HAL 작업은 사용자 재시도(fresh) 때만', actual: `portal job queued attempt ${pj.attempt} (${pj.waitReason || pj.wait_reason || ''}); HAL 에 같은 사이트의 site.deploy 실패 작업이 attempt 수만큼 쌓임`, repro: 'Agent 가 실패하는 배포를 요청하고 /api/sites/:id/jobs/:id 를 지켜보면 attempt 가 5초마다 증가', flows: [6, 23, 13, 20] });
// 선행 결함으로 영원히 진행 중인 배포를 사용자 취소·정리해 뒤 흐름(백업·이전·소유권)이 "진행 중 작업" 에 막히지 않게 한다
await s.run('사용자 취소(진행 중 작업) → 중간 결과 정리·예약 반환', async () => { const jid = s.list[0]?.detail?.jobId; const c = await alice.post(`/api/sites/${site.id}/jobs/${jid}/cancel`); const cl = await alice.post(`/api/sites/${site.id}/cleanup`); const st = (await alice.get(`/api/sites/${site.id}`)).json.site; return { cancel: c.status, cancelError: c.json?.error, cleanup: cl.status, cleanupError: cl.json?.error, siteCount: st.site_count_state, lifecycle: st.lifecycle }; }, { soft: true });
report.flow(6, 'failed', { steps: s.list, note: e.message, evidence: { halLog: env.hal.proc.lines.filter((l) => /site.deploy/.test(l)).slice(-3) } });
report.bug({ title: '관리형 배포 종단 흐름 실패(포털→HAL→Agent 저장본 전달)', file: 'agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle: client.download(p.bundleUrl))', route: 'site.deploy → Agent fetch 단계', expected: '절대 URL(bundleUrl=http://<portal>/internal/bundles/<token>)은 HAL base 를 붙이지 않고 그대로 내려받아야 한다', actual: e.message, repro: '사이트 생성→저장본→POST /api/sites/:id/deploy→HAL site.deploy→Agent site.deploy fetch 단계에서 "HAL 내려받기 실패" 또는 잘못된 URL 오류', flows: [6, 8, 9, 13, 14, 15, 23] });
} }
const deployed = deployJob && deployJob.status === 'done';
// ---- 7 도메인 연결
{ const s = steps(); try {
const dm = await s.run('도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)', async () => { const r = await alice.post('/api/domains', { name: 'aw-e2e.localhost', purpose: 'web', siteId: site.id, makePrimary: true }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); const txt = r.json.records?.find((x) => x.type === 'TXT'); const j = await waitFor(async () => { const x = await alice.get(`/api/domains/${r.json.domain.id}`); const jj = x.json?.job; return jj && ['awaiting_external', 'failed', 'done', 'needs_user'].includes(jj.status) ? jj : null; }, { timeoutMs: 30000, intervalMs: 1000 }); return { domainId: r.json.domain.id, txtName: txt?.name, txtValueShape: txt?.value ? txt.value.replace(/=.*/, '=…') : null, jobStatus: j?.status, waitReason: j?.waitReason || j?.wait_reason, copyable: !!txt }; });
await s.run('도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시', async () => { const l = await alice.get('/api/domains'); const d = l.json.domains.find((x) => x.id === dm.domainId); return { ownership: d?.ownership_state, dns: d?.dns_state, cert: d?.cert_state, primary: d?.is_primary }; });
state.domainId = dm.domainId;
await s.run('(정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다', async () => { const x = await alice.get(`/api/domains/${dm.domainId}`); const jid = x.json?.job?.id; const r = jid ? await alice.post(`/api/domains/${dm.domainId}/jobs/${jid}/cancel`) : null; await sleep(1500); const q = (await env.ops.get('/ops/api/jobs?status=queued,running&kind=domain.probe&limit=50')).json?.jobs?.length; if (q > 3) report.bug({ title: '포털 도메인 연결 작업이 외부 대기(awaiting_external) 중에도 5초마다 HAL domain.probe 작업을 새로 만들어 HAL 큐를 채움', file: 'portal/domains/service.mjs (domain.connect: idempotencyKey 에 attempt·30초 버킷 포함, awaitHal 의 retryLater(5s) 마다 새 작업)', route: 'POST /api/domains → HAL /portal/v1/jobs(domain.probe)', expected: '한 번 확인 요청에 HAL 작업 하나; 재확인은 사용자의 "다시 확인" 또는 완만한 주기', actual: `취소 전 HAL 큐에 domain.probe ${q}개(5초 간격)`, repro: '도메인 추가 뒤 /ops/api/jobs?kind=domain.probe 를 보면 5초마다 새 queued 작업', flows: [7, 22, 30] }); return { cancelled: r?.status, queuedProbes: q }; }, { soft: true });
report.needsInfra('도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인은 실제 DNS 와 공인 서버가 필요하다(흐름 7·8·11·14·15)');
report.flow(7, 'partial', { steps: s.list, note: '도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가', evidence: { dm } });
} catch (e) { report.flow(7, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '도메인 연결 흐름 실패', file: 'portal/domains/api.mjs', route: 'POST /api/domains', expected: 'TXT 안내 + 외부 대기', actual: e.message, repro: '30-sites.mjs 흐름 7', flows: [7] }); } }
report.flow(8, deployed ? 'partial' : 'not_verifiable_locally', { note: deployed ? '플랫폼 확인 주소(preview.localhost)로 HAL 외부 확인을 시도했으나 로컬에는 80 포트 웹서버가 없어 외부 확인 결과만 기록됨' : '외부 주소 확인은 공인 서버·DNS 필요', blockedBy: deployed ? null : 6, evidence: deployJob ? { externalCheck: deployJob.externalCheck } : {} });
// ---- 9 사이트 정보·통계
{ const s = steps(); try {
const ov = await s.run('개요: 배포 전/후에 따라 준비 단계 또는 실제 통계', async () => { const r = await alice.get(`/api/sites/${site.id}/overview`); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const o = r.json.overview; return { kind: o.kind, lifecycle: o.lifecycle, hasStats: o.visits24h != null, readiness: o.readiness || o.stage || null, disk: o.disk ? { used: o.disk.used, limit: o.disk.limit } : null }; });
const us = await s.run('사용량 탭(배정·실제 사용·마지막 계측)', async () => { const r = await alice.get(`/api/sites/${site.id}/usage`); return { allocations: (r.json.allocations || []).map((a) => a.resource) }; });
report.flow(9, deployed && ov.hasStats ? 'verified_local' : 'partial', { steps: s.list, note: deployed ? '개요·사용량 조회(Agent stub 가 수집한 값)' : '미배포 사이트는 통계 대신 준비 단계 표시(§8.1) 확인. 실제 접속·네트워크 통계는 배포 뒤 수집', blockedBy: deployed ? null : 6, evidence: { ov, us } });
} catch (e) { report.flow(9, 'partial', { steps: s.list, note: e.message }); } }
// ---- 23 실패 작업 재시도·되돌리기(배포 작업 기준)
{ const s = steps(); try {
const dep = await s.run('배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호', async () => { const r = await alice.get(`/api/sites/${site.id}/deployments`); const d = r.json.deployments?.[0]; return { status: d?.status, failedStep: d?.result?.failedStep, canRetry: d?.job?.canRetry, jobId: d?.job?.id }; });
const rt = await s.run('재시도 요청(POST /api/sites/:id/jobs/:jobId/retry)', async () => { if (!dep.jobId) throw new Error('작업 없음'); const r = await alice.post(`/api/sites/${site.id}/jobs/${dep.jobId}/retry`); if (r.status >= 500) report.bug({ title: '취소된 배포 작업의 재시도 요청이 500(서버 오류)으로 응답(작업 상태 오류는 4xx 여야 함)', file: 'portal/sites/api.mjs (jobs/:jobId/:action → lib/jobs JobError 를 HttpError 로 바꾸지 않음)', route: 'POST /api/sites/:id/jobs/:jobId/retry', expected: '409 job_not_retryable + 안내', actual: `${r.status} ${r.json?.error}`, repro: '취소된 작업에 retry', flows: [23] }); return { status: r.status, error: r.json?.error, jobStatus: r.json?.job?.status }; }, { soft: true });
if (rt && rt.status === 200) await s.run('재시도 작업 결과 대기', async () => { const j = await waitSiteJob(alice, site.id, dep.jobId, 60000); return { status: j?.status, error: j?.error }; }, { soft: true });
const rb = await s.run('되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부', async () => { const r = await alice.post(`/api/sites/${site.id}/rollback`); return { status: r.status, error: r.json?.error, message: r.json?.message }; });
report.flow(23, deployed ? 'partial' : 'partial', { steps: s.list, note: '실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요', blockedBy: deployed ? null : 6, evidence: { dep, rt, rb } });
} catch (e) { report.flow(23, 'partial', { steps: s.list, note: e.message }); } }
// ---- 27 마지막 사이트 수 동시 요청(밥의 개인 그룹: 베이직 1개)
{ const s = steps(); try {
const bob = state.bob; const personal = (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal'); await bob.post('/api/groups/switch', { groupId: personal.id });
await s.run('밥 개인 그룹: 베이직(사이트 1) 주문·승인', async () => orderAndApprove(env, bob, { productId: 'basic', key: 'e2e-bob-basic' }).then((r) => ({ granted: r.approve.granted })));
const [a, b] = await s.run('사이트 2개 생성·저장본', async () => Promise.all([createSiteWithVersion(env, bob, '동시 A'), createSiteWithVersion(env, bob, '동시 B')])).then((x) => x);
const race = await s.run('두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족)', async () => { const [ra, rb] = await Promise.all([bob.post(`/api/sites/${a.id}/deploy`, {}), bob.post(`/api/sites/${b.id}/deploy`, {})]); const ok = [ra, rb].filter((r) => r.status === 200).length; const bad = [ra, rb].find((r) => r.status !== 200); const good = [ra, rb].find((r) => r.status === 200); if (ok !== 1 || !bad || bad.json?.error !== 'precheck_failed') throw new Error(`${ra.status}/${rb.status} ${bad?.json?.error}`); state.bobDeploy = good ? { siteId: good === ra ? a.id : b.id, jobId: good.json.job?.id } : null; return { ok, blocked: bad.json.error, shortage: bad.json.detail?.items?.find((i) => i.id === 'sites')?.state }; });
await s.run('(정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환', async () => { if (!state.bobDeploy) return null; await sleep(3000); const c = await bob.post(`/api/sites/${state.bobDeploy.siteId}/jobs/${state.bobDeploy.jobId}/cancel`); const cl = await bob.post(`/api/sites/${state.bobDeploy.siteId}/cleanup`); return { cancel: c.status, cleanup: cl.status, unallocated: (await bob.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.unallocated }; }, { soft: true });
state.bobSites = [a, b]; state.bobPersonal = personal.id; report.flow(27, 'verified_local', { steps: s.list, note: '마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3)', evidence: { race } });
} catch (e) { report.flow(27, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '동시 예약 흐름 실패', file: 'portal/sites/service.mjs', route: 'POST /api/sites/:id/deploy ×2', expected: '하나만 성공', actual: e.message, repro: '30-sites.mjs 흐름 27', flows: [27] }); } }
}
function d_last(s) { return s.list.at(-1); }
+47
View File
@@ -0,0 +1,47 @@
/** 흐름 10·11·12·17·33: 사이트 대표 메일·메일함(가짜 AriMail) → DNS 확인·활성화(외부 대기) → 왕복 확인(로컬 불가) → 독립 메일 → 사용량 경고/100% → 전환 점검표. */
import { steps } from '../report.mjs';
import { waitFor } from '../util.mjs';
import { cancelDomainJob } from './30-sites.mjs';
export const BUDGET_MS = 60000;
const alloc = { mailboxes: 6, storageBytes: 2 * 1024 ** 3, monthlyMessages: 500 };
export async function run({ env, report, state }) {
const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [10, 11, 12, 17, 33]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; }
let svcId = null;
{ const s = steps(); try {
const svc = await s.run('사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시', async () => { const r = await alice.post('/api/mail/services', { kind: 'site_bound', siteId: site.id, domain: 'aw-e2e.localhost', alloc }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const d = env.am.state.domains.get('aw-e2e.localhost'); const cfg = env.am.state.configs.get('aw-e2e.localhost') || {}; return { id: r.json.service.id, status: r.json.service.status, tenant: !!d, ariwebKind: d?.ariweb?.kind, limitAccounts: cfg.LIMIT_ACCOUNTS, siteCountAllocated: (await alice.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.allocated }; });
svcId = svc.id; state.mailServiceId = svcId;
await s.run('같은 사이트에 두 번째 대표 메일 거부', async () => { const r = await alice.post('/api/mail/services', { kind: 'site_bound', siteId: site.id, domain: 'aw-e2e.localhost', alloc: { mailboxes: 1 } }); return r.json?.error; });
const mb = await s.run('메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음', async () => { const r = await alice.post(`/api/mail/services/${svcId}/mailboxes`, { local: 'kim', displayName: '김', quotaBytes: 100 * 1024 ** 2 }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const again = await alice.get(`/api/mail/services/${svcId}/mailboxes/${r.json.mailbox.id}`); const leaked = JSON.stringify(again.json).includes(r.json.initialPassword); return { id: r.json.mailbox.id, address: r.json.mailbox.address, passwordShownOnce: !!r.json.initialPassword, mustChange: r.json.mailbox.mustChangePassword, imapHost: r.json.program?.imap?.host, leakedOnReload: leaked, inArimail: !!(env.am.state.users.get('aw-e2e.localhost') || []).find((u) => u.login === 'kim') }; });
state.mailboxId = mb.id;
await s.run('초대 방식 메일함(링크 만료·재발송·사용 완료 상태)', async () => { const r = await alice.post(`/api/mail/services/${svcId}/mailboxes`, { local: 'lee', mode: 'invite', inviteEmail: 'lee@external.e2e.localhost' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const token = r.json.invite.url.split('/').pop(); const acc = await env.customer().post('/api/mail/invite/accept', { token, password: 'new-password-e2e-12' }); const again = await env.customer().post('/api/mail/invite/accept', { token, password: 'new-password-e2e-12' }); return { inviteState: r.json.mailbox.inviteState, accepted: acc.status, reuse: again.json?.error }; });
const dns = await s.run('DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing)', async () => { const r = await alice.post(`/api/mail/services/${svcId}/dns-check`); return { ok: r.json.result?.ok, missing: r.json.result?.summary?.missing, items: (r.json.result?.items || []).map((i) => i.kind + ':' + i.state) }; });
const act = await s.run('활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등', async () => { const a = await alice.post(`/api/mail/services/${svcId}/activate`); const b = await alice.post(`/api/mail/services/${svcId}/activate`); const j = await waitFor(async () => { const x = await alice.get(`/api/mail/services/${svcId}/job`); return x.json?.job && x.json.job.status !== 'queued' && x.json.job.status !== 'running' ? x.json.job : null; }, { timeoutMs: 20000, intervalMs: 500 }); return { status: j?.status, waitReason: j?.waitReason, sameJob: a.json?.job?.id === b.json?.job?.id, steps: (j?.steps || []).map((x) => x.id + ':' + x.state) }; });
state.mailJobId = act && (await alice.get(`/api/mail/services/${svcId}/job`)).json?.job?.id;
await s.run('보안 보고서(SPF·DKIM·DMARC·MX 상태 + 복사 값)', async () => { const r = await alice.get(`/api/mail/services/${svcId}/security`); return { items: (r.json.report?.items || []).map((i) => i.id + ':' + i.state), records: (r.json.report?.records || []).length }; });
report.needsInfra('메일 DNS(MX·SPF·DKIM·DMARC) 반영과 외부 송신·수신·회신 왕복은 실 도메인·실 AriMail(SMTP 587/수신함)·외부 검증 주소가 필요하다(흐름 10 활성화 완료·11·33 완료)');
report.flow(10, 'partial', { steps: s.list, note: '대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요', evidence: { svc, mb, dns, act } });
} catch (e) { report.flow(10, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '사이트 대표 메일·메일함 흐름 실패', file: 'portal/mail/api.mjs', route: 'POST /api/mail/services, /mailboxes', expected: '서비스·메일함 생성', actual: e.message, repro: '40-mail.mjs', flows: [10] }); } }
report.flow(11, 'not_verifiable_locally', { note: '외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨' });
// ---- 12 독립 메일
{ const s = steps(); try {
const d = await s.run('메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)', async () => { const r = await alice.post('/api/domains', { name: 'solo-e2e.localhost', purpose: 'mail', startCheck: false }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const c = await cancelDomainJob(alice, r.json.domain.id); return { id: r.json.domain.id, ownership: r.json.domain.ownership_state, jobCancelled: c }; });
const r = await s.run('독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)', async () => { const x = await alice.post('/api/mail/services', { kind: 'standalone', domain: 'solo-e2e.localhost', alloc: { mailboxes: 2, storageBytes: 1024 ** 3, monthlyMessages: 100 } }); if (x.status !== 200) throw new Error(`${x.status} ${x.text.slice(0, 300)}`); const u = (await alice.get('/api/usage')).json.resources; return { id: x.json.service.id, kind: x.json.service.kind, mailServicesAllocated: u.find((y) => y.resource === 'mail_services')?.allocated, sitesAllocated: u.find((y) => y.resource === 'sites')?.allocated }; });
state.standaloneMailId = r.id; const list = await alice.get('/api/mail/services');
report.flow(12, 'verified_local', { steps: s.list, note: '독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구', evidence: { r, listCount: list.json.services?.length, noBackupNote: list.json.notes?.noBackup } });
} catch (e) { report.flow(12, 'partial', { steps: s.list, note: e.message }); } }
// ---- 17 사용량 경고·100%(메일 저장 공간·월간 통수)
{ const s = steps(); try {
if (!svcId) throw new Error('메일 서비스 없음');
const warn = await s.run('가짜 AriMail 사용량 95%/월간 100% → 수집(refresh) → 구간·제한 사유·다음 KST 초기화·마지막 계측 표시', async () => { env.am.state.usage.set('aw-e2e.localhost', { storageBytes: Math.round(0.96 * alloc.storageBytes), sent: 300, received: 250, monthReset: '2026-10-31T15:00:00.000Z' }); const kim = (env.am.state.users.get('aw-e2e.localhost') || []).find((u) => u.login === 'kim'); if (kim) kim.bytes = Math.round(0.97 * (kim.quotaBytes || 100 * 1024 ** 2)); const u = (await alice.get(`/api/mail/services/${svcId}/usage?refresh=1`)).json; const ev = await alice.post('/api/usage/evaluate', {}); u._evaluate = { status: ev.status, notified: ev.json?.notified?.length ?? ev.json?.result?.notified?.length, limited: ev.json?.limited?.length ?? ev.json?.result?.limited?.length }; return { storageLevel: u.figures?.storage?.level, monthlyLevel: u.figures?.monthly?.level, nextReset: u.nextReset, measuredAt: !!u.measuredAt, limit100: u.notes?.limit100?.monthly, countingRule: u.lastUsage?.countingRule, evaluate: u._evaluate }; });
const notif = await s.run('알림 센터: 95%·100% 경고가 사건으로 묶여 표시(필수 알림)', async () => { const r = await alice.get('/api/notifications'); const items = r.json.notifications || r.json.items || []; const usage = items.filter((n) => n.category === 'usage' || /%/.test(n.title)); return { count: usage.length, titles: usage.slice(0, 4).map((n) => n.title), mandatory: usage.some((n) => n.mandatory) }; });
const lim = await s.run('사용량 화면 제한 플래그(/api/usage/limits)·그룹 요약', async () => { const r = await alice.get('/api/usage/limits'); const u = await alice.get('/api/usage'); return { limits: (r.json.limits || []).map((l) => l.resource + ':' + l.level), warnLevels: u.json.product?.warnLevels }; });
if (!notif.count) report.bug({ title: '메일 사용량 수집 뒤 서비스 단위 70/80/90/95/100 경고 알림이 생성되지 않음(수집과 경고 엔진 연결 누락 가능)', file: 'portal/mail/service.mjs (collectMailUsage) ↔ portal/usage/warnings.mjs (evaluate)', route: 'GET /api/mail/services/:id/usage?refresh=1 → POST /api/usage/evaluate', expected: '저장 공간 95%·월간 100% 수집 뒤 알림 센터에 경고/제한 알림(필수)', actual: { notifications: notif, limits: lim.limits, evaluate: warn.evaluate }, repro: '가짜 AriMail 사용량을 95%/100% 로 두고 refresh=1 수집 → /api/notifications 에 usage 알림 없음', flows: [17] });
report.flow(17, notif.count ? 'verified_local' : 'partial', { steps: s.list, note: '메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집)', evidence: { warn, notif, lim } });
} catch (e) { report.flow(17, 'partial', { steps: s.list, note: e.message }); } }
// ---- 33 전환 점검표
{ const s = steps(); try {
const ck = await s.run('사이트 대표→독립 전환 점검표(주소·메일함·별칭·전달·비밀번호·DNS·옵션·작업)', async () => { const r = await alice.get(`/api/mail/services/${svcId}/convert`); return { canConvert: r.json.checklist?.canConvert, items: (r.json.checklist?.items || []).map((i) => i.id + ':' + (i.ok ? 'ok' : 'x')) }; });
const tr = await s.run('DNS 미확인 상태에서 전환 요청은 차단', async () => { const r = await alice.post(`/api/mail/services/${svcId}/convert`, { confirm: 'aw-e2e.localhost' }); return { status: r.status, error: r.json?.error }; });
report.flow(33, 'partial', { steps: s.list, note: '전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인)', evidence: { ck, tr } });
} catch (e) { report.flow(33, 'partial', { steps: s.list, note: e.message }); } }
}
+35
View File
@@ -0,0 +1,35 @@
/** 흐름 13·14·15: 백업 옵션 구매(사이트별)·첫 백업(실제 HAL→Agent 저장소 분리), 복원(승인·보호 시점·사용자 확인), 서버 이전 경계값·요청. */
import { steps } from '../report.mjs';
import { waitFor } from '../util.mjs';
import { orderAndApprove } from './20-commerce.mjs';
export const BUDGET_MS = 90000;
async function waitJob(client, path, ms = 60000) { return waitFor(async () => { const r = await client.get(path); const j = r.json?.job || r.json?.restore?.job || null; return j && !['queued', 'running'].includes(j.status) ? j : null; }, { timeoutMs: ms, intervalMs: 1000 }); }
export async function run({ env, report, state }) {
const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [13, 14, 15]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; }
const cur = (await alice.get(`/api/sites/${site.id}`)).json.site; const deployed = !!cur.current_version;
{ const s = steps(); try {
await s.run('백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리', async () => orderAndApprove(env, alice, { productId: 'team', items: [{ optionId: 'backup', qty: 1, targetId: site.id }], key: 'e2e-backup-opt' }).then((r) => ({ granted: r.approve.granted, orderStatus: r.approve.order?.status })));
const el = await s.run('백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서)', async () => { const r = await alice.get(`/api/backup/sites/${site.id}`); return { eligible: r.json.purchase?.eligible, reasons: r.json.eligibility?.reasons, deferred: r.json.purchase?.deferred, policy: r.json.purchase?.policy ? Object.keys(r.json.purchase.policy) : null, status: r.status }; });
const opt = await s.run('옵션 활성화(확인 필수) → 첫 백업 예정 시각', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/option`, { confirm: true }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); return { status: r.json.option.status, firstDue: r.json.option.first_backup_due_at, nextRun: r.json.option.next_run_at }; });
const run1 = await s.run('지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/run`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const j = await waitFor(async () => { const x = await alice.get(`/api/backup/sites/${site.id}`); const b = (x.json.backups || [])[0]; const jj = x.json.option?.lastJob || x.json.lastJob || null; return (b && b.status !== 'running') || (jj && !['queued', 'running'].includes(jj.status)) ? { backup: b, job: jj, option: x.json.option } : null; }, { timeoutMs: 25000, intervalMs: 1500 }); const bj = (await alice.get(`/api/backup/sites/${site.id}`)).json; return { timedOut: !!j?.timedOut, optionStatus: bj.option?.status, lastJob: bj.option?.lastJob || bj.lastJob || null, siteServer: (await alice.get(`/api/sites/${site.id}`)).json.site?.server_id || null, backup: j?.backup ? { status: j.backup.status, verify: j.backup.verify_state, tier: j.backup.tier, location: j.backup.location ? j.backup.location.split(':')[0] : null } : null, job: j?.job ? { status: j.job.status, error: j.job.error } : null, optionFailures: j?.option?.consecutive_failures }; });
state.backupOk = run1.backup && run1.backup.status === 'done';
report.flow(13, state.backupOk ? 'verified_local' : 'partial', { steps: s.list, note: state.backupOk ? '옵션 구매·활성화·첫 백업(HAL→운영 Agent 생성→저장 Agent 보관→검증)' : (deployed ? '옵션 구매·활성화까지 확인, 백업 실행 결과는 증거 참조' : '옵션 구매·활성화까지 확인. 실제 백업 생성은 배포된 사이트 파일이 있어야 함(흐름 6 결함에 막힘)'), blockedBy: state.backupOk || deployed ? null : 6, evidence: { el, opt, run1 } });
} catch (e) { report.flow(13, 'partial', { steps: s.list, note: e.message, blockedBy: deployed ? null : 6 }); } }
{ const s = steps(); try {
const b = (await alice.get(`/api/backup/sites/${site.id}`)).json.backups?.find((x) => x.status === 'done');
if (!b) throw new Error('완료된 백업이 없어 복원을 요청할 수 없음');
const info = await s.run('복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식)', async () => (await alice.get(`/api/backup/backups/${b.id}`)).json.restoreInfo);
const rq = await s.run('복원 요청 → 운영자 승인 대기 · 중복 요청 없음', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const r2 = await alice.post(`/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); return { id: r.json.restore?.id, status: r.json.restore?.status, dup: r2.json?.created, finalConfirmation: r.json.finalConfirmation }; });
const ap = await s.run('운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등)', async () => { const bad = await env.relay('POST', `/backup/restores/${rq.id}/approve`, { operator: 'e2e', confirm: { groupId: 'wrong', siteId: site.id, backupAt: b.created_at } }); const ok = await env.relay('POST', `/backup/restores/${rq.id}/approve`, { operator: 'e2e', confirm: { groupId: site.group_id, siteId: site.id, backupAt: b.created_at } }); return { bad: bad.json?.error, ok: ok.status, created: ok.json?.created }; });
const j = await s.run('9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능)', async () => { const jj = await waitJob(alice, `/api/backup/restores/${rq.id}`, 60000); return { status: jj?.status, steps: (jj?.steps || []).map((x) => x.id + ':' + x.state), actions: jj?.actions?.map((a) => a.id), error: jj?.error }; });
if (j.status === 'needs_user') await s.run('사용자 확인 → 완료', async () => { const r = await alice.post(`/api/backup/restores/${rq.id}/decision`, { action: 'confirm' }); const jj = await waitJob(alice, `/api/backup/restores/${rq.id}`, 30000); return { status: jj?.status }; });
report.flow(14, j.status === 'done' || j.status === 'needs_user' ? 'partial' : 'partial', { steps: s.list, note: '복원 요청·운영자 최종 확인(불일치 거부·승인 멱등)·단계 진행까지. 외부 사이트 확인은 공인 서버 필요', evidence: { info, rq, ap, j } });
} catch (e) { report.flow(14, 'partial', { steps: s.list, note: e.message, blockedBy: state.backupOk ? null : 13 }); } }
{ const s = steps(); try {
const b = await s.run('이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원)', async () => { const r = await alice.get(`/api/relocation/sites/${site.id}`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); return { auto: r.json.auto, boundaries: r.json.boundaries ? { lastCancel: r.json.boundaries.customer?.lastCancelLabel, revertHours: r.json.boundaries.customer?.revertHours, cost: r.json.boundaries.customer?.cost, platformCost: r.json.boundaries.platform?.cost } : null, servers: r.json.servers?.length }; });
const req = await s.run('고객 요청(유료·승인 대기) 또는 조건 미충족 사유', async () => { const r = await alice.post(`/api/relocation/sites/${site.id}`, { confirm: true, reason: 'e2e' }); return { status: r.status, error: r.json?.error, relocation: r.json?.relocation ? { status: r.json.relocation.status, kind: r.json.relocation.kind, cost: r.json.relocation.cost } : null, message: r.json?.message }; });
const cur2 = req.relocation ? (await alice.get(`/api/relocation/sites/${site.id}`)).json : null; const relId = cur2?.current?.id || cur2?.relocation?.id || cur2?.relocations?.[0]?.id || null; state.relocationId = relId;
if (relId) await s.run('전환 전 고객 취소 가능(요청 취소 → 소유권 이전 등 다른 작업 차단 해제)', async () => { const r = await alice.post(`/api/relocation/${relId}/cancel`); return { status: r.status, rstatus: r.json?.relocation?.status, error: r.json?.error }; }, { soft: true });
report.flow(15, 'partial', { steps: s.list, note: '이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요', blockedBy: deployed ? null : 6, evidence: { b, req } });
} catch (e) { report.flow(15, 'partial', { steps: s.list, note: e.message }); } }
}
+47
View File
@@ -0,0 +1,47 @@
/** 흐름 16·28: 외부 배포 사이트 — 동의(버전 기록) → Agent 토큰(1회) → 실제 외부 Agent 프로세스 등록·연결 → 배포 → 재배포 → 제거 → 실패 정리. */
import { steps } from '../report.mjs';
import { waitFor, sleep } from '../util.mjs';
import { startAgent } from '../boot.mjs';
import { createSiteWithVersion, waitSiteJob } from './30-sites.mjs';
export const BUDGET_MS = 120000;
export async function run({ env, report, state }) {
const alice = state.alice; if (!alice) { report.flow(16, 'partial', { note: '선행 흐름 없음', blockedBy: 1 }); report.flow(28, 'partial', { note: '선행 흐름 없음', blockedBy: 1 }); return; }
const s = steps(); let site = null; let tok = null; let agent = null;
try {
site = await s.run('외부 배포 사이트 생성(초안) + 저장본', async () => createSiteWithVersion(env, alice, 'E2E 외부 사이트', { deployMode: 'external' }));
const mode = await s.run('배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전', async () => { const r = await alice.get(`/api/sites/${site.id}/mode`); return { notProvided: r.json.external?.notProvided, consentVersion: r.json.consent?.version, agreed: r.json.consent?.agreed, cost: r.json.external?.cost }; });
await s.run('동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전)', async () => { const a = await alice.post(`/api/sites/${site.id}/external`, {}); const c = await alice.post(`/api/sites/${site.id}/consent`, { version: mode.consentVersion, agree: true }); return { before: a.json?.error, consent: c.status }; });
tok = await s.run('외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)', async () => { const r = await alice.post(`/api/sites/${site.id}/external`, { label: '고객 서버' }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)} | portal: ${env.portal.proc.lines.filter((l) => /external 500/.test(l)).slice(-1).join('').slice(0, 300)}`); return { token: r.json.token, install: r.json.installCommand?.replace(/--token \S+/, '--token …'), expiresAt: r.json.expiresAt, status: r.json.agent?.status }; }, { soft: true });
if (!tok) {
report.bug({ title: '외부 Agent 토큰 발급이 DB 제약(external_agents.hal_agent_id NOT NULL)으로 500', file: 'portal/sites/service.mjs (createExternalAgent) / portal/sites/model.mjs (external_agents.hal_agent_id)', route: 'POST /api/sites/:id/external', expected: 'HAL 토큰 발급 응답의 agentId 가 null(등록 전)이어도 토큰·설치 명령을 돌려주고, Agent 등록 뒤 by-site 조회로 hal_agent_id 를 채운다', actual: env.portal.proc.lines.filter((l) => /external 500/.test(l)).slice(-1).join('').slice(0, 300) || 'ERR_SQLITE_ERROR', repro: '외부 배포 사이트 → 동의 → POST /api/sites/:id/external → 500 "NOT NULL constraint failed: external_agents.hal_agent_id"', flows: [16, 28] });
// 포털 결함을 우회하지 않고, HAL·Agent 쪽 외부 Agent 경로만 별도로 확인한다(흐름 28 의 Agent 연결·배포·제거·실패 정리)
const halTok = await s.run('[HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키)', async () => { const r = await env.halSigned('POST', '/portal/v1/external-agents/token', { groupId: site.group_id, siteId: site.id, label: 'e2e' }); if (r.status !== 200) throw new Error(r.text?.slice(0, 200)); return { token: r.json.token, expiresAt: r.json.expiresAt }; }, { soft: true });
if (halTok) {
agent = await s.run('[HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected', async () => { const a = await startAgent(env, { name: 'ext-1', role: 'web', token: halTok.token, external: true, siteId: site.id }); const st = await waitFor(async () => { const r = await env.halSigned('GET', `/portal/v1/external-agents/by-site/${site.id}`); return r.json?.agent?.status === 'connected' ? r.json.agent : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { agentId: a.agentId, status: st?.status, siteId: st?.siteId }; }, { soft: true }) && env.agents.at(-1);
const run = async (kind, payload) => { const r = await env.halSigned('POST', '/portal/v1/jobs', { kind, target: agent.agentId, payload, idempotencyKey: `e2e:${kind}:${Date.now()}` }); const j = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${r.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: j?.status, error: j?.error, errorCode: j?.errorCode, result: j?.result && { version: j.result.version, leftovers: j.result.leftovers, removed: j.result.removed } }; };
if (agent) {
const { mkdirSync, writeFileSync, existsSync } = await import('node:fs'); const bdir = `${agent.dir}/bundle-v1`; mkdirSync(bdir, { recursive: true }); writeFileSync(`${bdir}/index.html`, '<h1>ext marker-ext-1</h1>');
const d1 = await s.run('[HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성', async () => ({ ...(await run('site.deploy', { siteId: site.id, versionRef: 'v1', bundlePath: bdir, domains: [], expectMarker: 'marker-ext-1' })), layout: existsSync(`${agent.roots.sites}/${site.id}/current`) }), { soft: true });
const d2 = await s.run('[HAL 직접] 재배포(v2) → 이전 버전 기록', async () => run('site.deploy', { siteId: site.id, versionRef: 'v2', bundlePath: bdir, domains: [], expectMarker: 'marker-ext-1' }), { soft: true });
const d3 = await s.run('[HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고', async () => run('site.deploy', { siteId: site.id, versionRef: 'v3', bundlePath: bdir, domains: [], expectMarker: 'no-such-marker' }), { soft: true });
const d4 = await s.run('[HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부', async () => { const a = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'site.deploy', target: agent.agentId, payload: { siteId: 'other', bundlePath: bdir }, idempotencyKey: 'e2e-mismatch' }); const ja = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${a.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 10000, intervalMs: 1000 }); const b = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'backup.create', target: agent.agentId, payload: { siteId: site.id }, idempotencyKey: 'e2e-kind' }); const jb = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${b.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 10000, intervalMs: 1000 }); return { mismatch: ja?.errorCode, kind: jb?.errorCode }; }, { soft: true });
const d5 = await s.run('[HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거', async () => ({ ...(await run('site.remove', { siteId: site.id, keepData: false })), layoutAfter: existsSync(`${agent.roots.sites}/${site.id}/current`) }), { soft: true });
const okAll = d1?.status === 'done' && d2?.status === 'done' && d3?.status === 'failed' && d5?.status === 'done';
report.flow(28, okAll ? 'partial' : 'partial', { steps: s.list, note: 'HAL·외부 Agent 경로(등록·연결·배포·재배포·실패 시 이전 버전 유지와 남은 파일·다른 사이트/허용되지 않는 종류 거부·제거)는 확인. 포털 화면 경로는 토큰 발급 결함으로 막힘', evidence: { halTok: { expiresAt: halTok.expiresAt }, d1, d2, d3, d4, d5 } });
}
}
report.flow(16, 'failed', { steps: s.list, note: '동의(버전)·토큰 발급 거부/허용 규칙까지 확인했으나 토큰 발급 API 가 DB 제약으로 500 — 결함 항목 참조', evidence: { mode } }); return;
}
agent = await s.run('고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속)', async () => { const a = await startAgent(env, { name: 'ext-1', role: 'web', token: tok.token, external: true, siteId: site.id }); return { agentId: a.agentId }; }) && env.agents.at(-1);
const conn = await s.run('포털 연결 확인(외부 Agent 상태 connected)', async () => { const r = await waitFor(async () => { const x = await alice.get(`/api/sites/${site.id}/external`); return x.json?.agent?.status === 'connected' ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: r?.agent?.status, stale: r?.agent?.stale, staleReason: r?.agent?.staleReason, halAgentId: r?.agent?.hal_agent_id }; });
if (conn.status !== 'connected') { report.bug({ title: '외부 Agent 가 HAL 에 등록되어도 포털 화면은 연결 상태를 알지 못함', file: 'portal/sites/service.mjs (createExternalAgent/refreshExternalAgent)', route: 'GET /api/sites/:id/external', expected: 'Agent 등록 뒤 connected', actual: conn, repro: '외부 토큰 발급 → agent register --external --site <id> → GET /api/sites/:id/external. HAL 토큰 발급 응답의 agentId 가 null 이라 포털 external_agents.hal_agent_id 가 비어 getExternalAgent(null) 로 조회됨 — by-site 조회(getExternalAgentBySite)로 보완 필요', flows: [16, 28] }); }
const pc = await s.run('배포 전 확인(외부: 관리형 용량 확인 없음)', async () => { const r = await alice.get(`/api/sites/${site.id}/precheck`); return { ok: r.json.precheck?.ok, blocks: (r.json.precheck?.items || []).filter((i) => i.state === 'block').map((i) => i.id) }; });
const dep = await s.run('외부 배포 → 외부 Agent 가 site.deploy 수행', async () => { const r = await alice.post(`/api/sites/${site.id}/deploy`, {}); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const j = await waitSiteJob(alice, site.id, r.json.job.id, 60000); return { status: j?.status, error: j?.error, errorCode: j?.errorCode, steps: (j?.steps || []).map((x) => x.id + ':' + x.state) }; }, { soft: true });
const ov = await s.run('외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과', async () => { const r = await alice.get(`/api/sites/${site.id}/overview`); const o = r.json.overview; return { kind: o.kind, disk: o.disk, health: o.health, notProvided: o.notProvided, agent: o.agent?.status, lastDeployOk: o.lastDeployResult?.ok }; });
const rm = await s.run('배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리)', async () => { const r = await alice.post(`/api/sites/${site.id}/external/remove`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const j = await waitSiteJob(alice, site.id, r.json.job?.id || r.json.id, 40000); return { status: j?.status, error: j?.error }; }, { soft: true });
const rv = await s.run('Agent 연결 해제(revoke) → 상태 없음', async () => { const r = await alice.del(`/api/sites/${site.id}/external`); const x = await alice.get(`/api/sites/${site.id}/external`); return { status: r.status, agent: x.json?.agent?.status || null }; }, { soft: true });
const deployOk = dep && dep.status === 'done';
report.flow(16, deployOk ? 'verified_local' : 'partial', { steps: s.list, note: deployOk ? '동의→토큰→실제 외부 Agent 등록·연결→배포→개요(제공되지 않음)→제거' : '동의(버전)·토큰 1회·실제 외부 Agent 등록·개요의 제공되지 않음 표기까지 확인. 배포 결과는 증거 참조', evidence: { mode, tok: { install: tok?.install, expiresAt: tok?.expiresAt }, conn, pc, dep, ov } });
report.flow(28, deployOk && rm && rm.status === 'done' ? 'verified_local' : 'partial', { steps: s.list, note: '외부 Agent 연결·제거·해제 API 확인. 재배포·실패 정리는 배포 성공이 필요(증거 참조)', evidence: { conn, dep, rm, rv } });
} catch (e) { report.flow(16, 'failed', { steps: s.list, note: e.message }); report.flow(28, 'partial', { steps: s.list, note: e.message }); report.bug({ title: '외부 배포 흐름 실패', file: 'portal/sites/api.mjs', route: '/api/sites/:id/external', expected: '동의→토큰→연결→배포', actual: e.message, repro: '60-external.mjs', flows: [16, 28] }); }
}
+24
View File
@@ -0,0 +1,24 @@
/** 흐름 18(+13.2/13.3 보조): 상품 축소 미리보기 → 보류(정리 목록) → 배정 축소 정리 → 재검증 → 축소 주문·승인 → 권리 교체. 배정 변경 동시성도 확인. */
import { steps } from '../report.mjs';
import { signupVerified } from './10-identity.mjs';
import { orderAndApprove } from './20-commerce.mjs';
import { cancelDomainJob } from './30-sites.mjs';
export const BUDGET_MS = 40000;
export async function run({ env, report, state }) {
const alice = state.alice; if (!alice || !state.mailServiceId) { report.flow(18, 'partial', { note: '선행 흐름 없음', blockedBy: 10 }); return; }
const s = steps();
try {
const basic = (await alice.get('/api/commerce/products')).json.products.find((p) => p.productId === 'basic');
const alloc = await s.run('배정 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대) + 마지막 단위 동시 요청', async () => { const sub = `mail_service:${state.mailServiceId}`; const r = await alice.get(`/api/usage/allocate/${sub}`); const mb = r.json.resources?.find((x) => x.resource === 'mailboxes'); const [a, b] = await Promise.all([alice.post(`/api/usage/allocate/${sub}`, { resource: 'mailboxes', amount: mb.purchased }), alice.post(`/api/usage/allocate/${sub}`, { resource: 'mailboxes', amount: mb.purchased })]); return { purchased: mb?.purchased, mine: mb?.mine, others: mb?.others, unallocated: mb?.unallocated, race: [a.status, b.status].sort(), mineAfter: (await alice.get(`/api/usage/allocate/${sub}`)).json.resources?.find((x) => x.resource === 'mailboxes')?.mine }; });
// 축소는 별도 고객(캐럴: 팀 상품 + 대표 메일 6개 배정)으로 — 앨리스 그룹의 백업·독립 메일 보존 상태와 섞이지 않게
const carol = await signupVerified(env, 'carol@e2e.localhost', '캐럴'); await orderAndApprove(env, carol, { productId: 'team', key: 'e2e-carol-team' });
const csite = (await carol.post('/api/sites', { name: '캐럴 사이트', projectMode: 'none' })).json.site; const cd = await carol.post('/api/domains', { name: 'carol-e2e.localhost', purpose: 'web', siteId: csite.id, startCheck: false }); if (cd.json?.domain?.id) await cancelDomainJob(carol, cd.json.domain.id);
const cm = await carol.post('/api/mail/services', { kind: 'site_bound', siteId: csite.id, domain: 'carol-e2e.localhost', alloc: { mailboxes: 6, storageBytes: 1024 ** 3, monthlyMessages: 100 } }); if (cm.status !== 200) throw new Error('캐럴 메일 생성 실패 ' + cm.text.slice(0, 200));
const pv = await s.run('축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시', async () => { const r = await carol.post('/api/commerce/downsize/preview', { productVersionId: basic.id }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); return { fits: r.json.fits, shortages: (r.json.shortages || []).map((x) => x.resource + ':' + x.reduceBy) }; });
const hold = await s.run('축소 요청 → 보류(사용 중 권리 즉시 회수 없음)', async () => { const r = await carol.post('/api/commerce/downsize/request', { productVersionId: basic.id, acceptTerms: true }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); const ent = (await carol.get('/api/commerce/billing')).json.entitlements?.find((e) => e.resource === 'sites'); return { fits: r.json.fits, hold: r.json.hold?.status, sitesStillPurchased: ent?.purchased }; });
const clean = await s.run('정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성', async () => { const a = await carol.post(`/api/usage/allocate/mail_service:${cm.json.service.id}`, { resource: 'mailboxes', amount: 5 }); const r = await carol.post('/api/commerce/downsize/revalidate'); return { allocate: a.status, allocateError: a.json?.error, fits: r.json.fits, shortages: (r.json.shortages || []).map((x) => x.resource + ':' + x.reduceBy), order: r.json.order?.status }; });
let applied = null;
if (clean.fits && clean.order) { applied = await s.run('축소 주문 입금 확인·승인 → 권리 교체(베이직)', async () => { const o = (await carol.get('/api/commerce/orders')).json.orders.find((x) => x.kind === 'downsize'); await env.relay('POST', `/commerce/orders/${o.id}/paid`, { ref: 'e2e' }); const ap = await env.relay('POST', `/commerce/orders/${o.id}/approve`, {}); const b = await carol.get('/api/commerce/billing'); return { approve: ap.status, sites: b.json.entitlements?.find((e) => e.resource === 'sites')?.purchased, mailboxes: b.json.entitlements?.find((e) => e.resource === 'mailboxes')?.purchased, product: b.json.subscriptions?.[0]?.product_name || b.json.subscriptions?.[0]?.product_version_id }; }); state.downsized = true; }
report.flow(18, applied ? 'verified_local' : 'partial', { steps: s.list, note: applied ? '축소 미리보기→보류→정리(배정 축소·독립 메일 해지)→재검증 적합→축소 주문·승인→권리 교체' : '축소 보류와 정리 목록까지 확인(재검증 결과는 증거 참조)', evidence: { alloc, pv, hold, clean, applied } });
} catch (e) { report.flow(18, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '상품 축소 보류·재검증 흐름 실패', file: 'portal/commerce/api.mjs', route: '/api/commerce/downsize/*', expected: '보류→정리→적용', actual: e.message, repro: '65-usage.mjs', flows: [18] }); }
}
+46
View File
@@ -0,0 +1,46 @@
/** 흐름 19·20·21·32·34: 사이트 수 반환 점검표·대표 메일 전환 조건, 소유권 이전(요청→수락→보류→재확인→심사·승인→원자적 완료), 중지·해지·보존 표시, Source 옵션 만료 읽기 전용. */
import { steps } from '../report.mjs';
import { orderAndApprove } from './20-commerce.mjs';
export const BUDGET_MS = 60000;
export async function run({ env, report, state }) {
const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [19, 20, 21, 32, 34]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; }
const cur = (await alice.get(`/api/sites/${site.id}`)).json.site; const live = ['live', 'needs_domain'].includes(cur.lifecycle);
// ---- 32 Source 옵션
{ const s = steps(); try {
await s.run('Source 옵션 주문(대상 사이트)·승인 → source 권리', async () => orderAndApprove(env, alice, { productId: 'team', items: [{ optionId: 'source', qty: 1, targetId: site.id }], key: 'e2e-source-opt' }).then((r) => ({ granted: r.approve.granted })), { soft: true });
const act = await s.run('Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보', async () => { const a = await alice.post(`/api/source/${site.id}/activate`); const r = await alice.get(`/api/source/${site.id}`); return { activate: a.status, error: a.json?.error, status: r.json.option?.status, provider: r.json.source?.provider, changes: r.json.source?.changes?.length, clone: !!r.json.cloneInfo }; });
const ex = await s.run('export(저장본 내려받기)', async () => { const r = await alice.get(`/api/source/${site.id}/export`); return { status: r.status, type: r.headers['content-type'] }; });
const un = await s.run('옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단', async () => { const r = await alice.post(`/api/source/${site.id}/unlink`); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); return { status: r.json.option?.status, deleteAfter: r.json.option?.delete_after, readonlyUntil: r.json.option?.readonly_until }; });
const sweep = await s.run('삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음', async () => { const r = await env.relay('POST', '/source/sweep', {}); const x = await alice.get(`/api/source/${site.id}`); return { sweep: r.status, statusAfter: x.json.option?.status }; });
report.flow(32, 'partial', { steps: s.list, note: '활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인)', evidence: { act, ex, un, sweep } });
} catch (e) { report.flow(32, 'partial', { steps: s.list, note: e.message }); } }
// ---- 21·34 생명주기 표시
{ const s = steps(); try {
const v = await s.run('§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림', async () => { const r = await alice.get(`/api/lifecycle/sites/${site.id}`); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const view = r.json.view || {}; return { state: view.site?.label, siteCount: view.siteCountConsumed, publicAccess: view.publicAccess, retention: view.retentionPolicy, pauseImpactKeys: Object.keys(r.json.pauseImpact || {}), cancelImpactKeys: Object.keys(r.json.cancelImpact || {}), steps14: r.json.siteCount?.steps?.length }; });
const pause = await s.run('일시 중지(확인 필수) 또는 현재 상태에서 거부 사유', async () => { const r0 = await alice.post(`/api/lifecycle/sites/${site.id}/pause`, {}); const r = await alice.post(`/api/lifecycle/sites/${site.id}/pause`, { confirm: true }); return { noConfirm: r0.json?.error, status: r.status, error: r.json?.error, lifecycle: r.json?.site?.lifecycle }; });
if (pause.status === 200) await s.run('재개', async () => { const r = await alice.post(`/api/lifecycle/sites/${site.id}/resume`); return { status: r.status, lifecycle: r.json?.site?.lifecycle }; }, { soft: true });
const mailLc = await s.run('메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공)', async () => { if (!state.standaloneMailId) return { skipped: true }; const r = await alice.get(`/api/lifecycle/mail/${state.standaloneMailId}`); return { status: r.status, sendReceive: r.json.view?.sendReceive, retentionUntil: !!r.json.view?.retentionUntil, deleteAfter: !!r.json.view?.deleteAfter, backupNote: r.json.view?.backupNote }; });
report.flow(21, 'partial', { steps: s.list, note: live ? '중지·재개·생명주기 화면 확인' : '생명주기 화면·중지/해지 영향 안내·보존 정책 표시 확인. 운영 중 사이트의 실제 중지·해지는 배포 성공(흐름 6) 필요', blockedBy: live ? null : 6, evidence: { v, pause, mailLc } });
report.flow(34, 'partial', { steps: s.list, note: '해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인)', blockedBy: live ? null : 6, evidence: { v, mailLc } });
} catch (e) { report.flow(21, 'partial', { steps: s.list, note: e.message }); report.flow(34, 'partial', { steps: s.list, note: e.message }); } }
// ---- 19 사이트 수 반환 점검표
{ const s = steps(); try {
const ck = await s.run('§14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션)', async () => { const r = await alice.get(`/api/lifecycle/sites/${site.id}/site-count-return`); return { ok: r.json.checklist?.ok, blocking: r.json.checklist?.blocking, steps: r.json.checklist?.steps?.length }; });
const keep = await s.run('메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙', async () => { const r = await alice.post(`/api/lifecycle/sites/${site.id}/site-count-return`, { mailChoice: 'keep', acknowledge: true, confirm: true }); return { status: r.status, error: r.json?.error, detail: r.json?.detail ? Object.keys(r.json.detail) : null }; });
report.flow(19, 'partial', { steps: s.list, note: '반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인)', blockedBy: live ? null : 6, evidence: { ck, keep } });
} catch (e) { report.flow(19, 'partial', { steps: s.list, note: e.message }); } }
// ---- 20 소유권 이전 (앨리스 팀 → 밥 개인)
{ const s = steps(); try {
const bob = state.bob; if (!bob) throw new Error('밥 없음'); await bob.post('/api/groups/switch', { groupId: state.bobPersonal || (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal').id });
const pv = await s.run('이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목)', async () => { const r = await alice.post('/api/transfer/preview', { siteIds: [site.id] }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); return { sites: r.json.scope?.sites?.length, domains: r.json.scope?.sites?.[0]?.domains?.length, siteMail: r.json.scope?.sites?.[0]?.siteMail?.length, needed: r.json.scope?.needed, excluded: r.json.scope?.excluded?.length, jobs: r.json.scope?.jobs }; });
const tsite = (await alice.post('/api/sites', { name: '이전 대상 사이트', projectMode: 'none' })).json.site;
const rq = await s.run('요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림', async () => { const r = await alice.post('/api/transfer', { toEmail: 'bob@e2e.localhost', siteIds: [tsite.id], confirm: true }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const n = await bob.get('/api/notifications'); return { id: r.json.transfer.id, status: r.json.transfer.status, expiresAt: r.json.transfer.expires_at, bobNotified: (n.json.notifications || n.json.items || []).some((x) => /이전|소유권/.test(x.title)) }; });
await s.run('(정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개)', async () => { const e = await alice.post(`/api/backup/sites/${site.id}/option/end`, { confirm: true }); const a = state.mailServiceId ? await alice.post(`/api/usage/allocate/mail_service:${state.mailServiceId}`, { resource: 'mailboxes', amount: 5 }) : null; return { endBackup: e.status, endError: e.json?.error, alloc: a?.status, allocError: a?.json?.error }; }, { soft: true });
const acc = await s.run('받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내)', async () => { const r = await bob.post(`/api/transfer/${rq.id}/accept`, { confirm: true }); return { status: r.status, tstatus: r.json?.transfer?.status, hold: r.json?.transfer?.hold }; });
if (acc.tstatus === 'on_hold') await s.run('받는 쪽 정리(실패 배포의 예약 반환) → 재확인 → 심사 단계', async () => { for (const bs of state.bobSites || []) await bob.post(`/api/sites/${bs.id}/cleanup`); const r = await bob.post(`/api/transfer/${rq.id}/recheck`); return { status: r.json?.transfer?.status, hold: r.json?.transfer?.hold }; }, { soft: true });
const ap = await s.run('운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각', async () => { const bad = await env.relay('POST', `/transfer/${rq.id}/approve`, { operator: 'e2e', review: {}, confirm: { fromGroupId: tsite.group_id, toGroupId: 'x' } }); const to = (await alice.get(`/api/transfer/${rq.id}`)).json.transfer?.to_group_id; const ok = await env.relay('POST', `/transfer/${rq.id}/approve`, { operator: 'e2e', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: tsite.group_id, toGroupId: to } }); return { bad: bad.json?.error, ok: ok.status, status: ok.json?.transfer?.status, cutover: ok.json?.transfer?.billing_cutover_at, error: ok.json?.error }; });
const after = await s.run('완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사', async () => { const a = await alice.get(`/api/sites/${tsite.id}`); const b = await bob.get(`/api/sites/${tsite.id}`); const au = await bob.get('/api/audit?action=ownership.complete'); return { aliceSees: a.status, bobSees: b.status, bobUsageSites: (await bob.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.allocated, audit: (au.json.items || [])[0]?.immutable }; });
report.flow(20, ap.status === 'completed' ? 'verified_local' : 'partial', { steps: s.list, note: ap.status === 'completed' ? '미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사' : '요청·수락·보류·심사 단계 확인(완료 여부는 증거 참조)', evidence: { pv, rq, acc, ap, after } });
} catch (e) { report.flow(20, 'partial', { steps: s.list, note: e.message }); } }
}
+67
View File
@@ -0,0 +1,67 @@
/** 흐름 22·25·26·29·30·31: HAL 우선 배포 순서·모듈 배포(대상별 결과)·재해복구 훈련·HAL/포털 재시작 뒤 상태 유지·HAL 장애 중 포털 표시·긴급 복구 거부/정상 경로. */
import { steps } from '../report.mjs';
import { waitFor, sleep } from '../util.mjs';
export const BUDGET_MS = 120000;
async function waitOpsJob(env, id, ms = 40000) { return waitFor(async () => { const r = await env.ops.get(`/ops/api/jobs/${id}`); const j = r.json?.job; return j && !['queued', 'running'].includes(j.status) && !(r.json.children?.children || []).some((c) => ['queued', 'running'].includes(c.status)) ? r.json : null; }, { timeoutMs: ms, intervalMs: 800 }); }
export async function run({ env, report, state }) {
const ctl = env.servers.control; const web = env.servers.web; const bk = env.servers.backup;
/** 진단: HAL 작업 엔진에 멈춘 작업이 있는지(앞선 흐름이 남긴 큐·실행 중 작업) */
const stuck = async () => { const r = await env.ops.get('/ops/api/jobs?status=queued,running&limit=20'); return (r.json?.jobs || []).map((j) => ({ id: j.id, kind: j.kind, status: j.status, attempt: j.attempt, step: j.currentStep?.id, detail: (j.currentStep?.detail || j.waitReason || '').slice(0, 120), updatedAt: j.updatedAt })); };
const stuckBefore = await stuck();
// 앞 흐름이 남긴 queued HAL 작업(도메인 확인 재제출 결함)을 운영자 취소로 비워 모듈 배포가 큐에서 차례를 받게 한다. 실행 중(running) 작업은 끝날 때까지 기다린다(엔진이 한 번에 하나만 실행).
for (const j of stuckBefore.filter((x) => x.status === 'queued' && x.kind !== 'module.deploy')) await env.ops.post(`/ops/api/jobs/${j.id}/cancel`, {});
await waitFor(async () => (await stuck()).filter((x) => x.status === 'running').length === 0 ? true : null, { timeoutMs: 45000, intervalMs: 1000 });
report.flow(22, 'partial', { note: '시작 전 진단', evidence: { stuckBefore } });
// ---- 22 HAL 우선 배포
{ const s = steps(); try {
const rel = await s.run('릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서', async () => { const r = await env.ops.get('/ops/api/releases'); const h = await env.ops.get('/api/health'); return { releases: r.json.releases.map((x) => x.version + ':' + x.status), running: h.json.bootstrap, order: r.json.deployOrder }; });
const hf = await s.run('HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부', async () => { const r = await env.ops.post('/ops/api/modules/portal/deploy', { version: '0.1.2', serverIds: [ctl.serverId], nonce: 'e2e-hf' }); const j = await waitOpsJob(env, r.json.job.id, 20000); return { status: j?.job?.status, error: j?.job?.errorCode || j?.job?.error }; });
const na = await s.run('승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시', async () => { const h = await env.ops.get('/api/health'); return { manualInstallLocked: h.json.bootstrap.manualInstallLocked, complete: h.json.bootstrap.complete }; });
const dep = await s.run('HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인)', async () => { const r = await env.ops.post('/ops/api/modules/hal/deploy', { version: '0.1.1', serverIds: [ctl.serverId], nonce: 'e2e-hal' }); const j = await waitOpsJob(env, r.json.job.id, 10000); const x = await env.ops.get(`/ops/api/jobs/${r.json.job.id}`); return { status: x.json?.job?.status, steps: x.json?.job?.steps?.map((y) => y.id + ':' + y.state), error: x.json?.job?.error, children: x.json?.children?.children?.map((c) => c.status + ':' + (c.error || '').slice(0, 120)), agentJobs: x.json?.agentJobs?.map((a) => a.kind + ':' + a.status + ':' + (a.error || '').slice(0, 120)), timedOut: !!j?.timedOut }; });
if (dep.timedOut) report.bug({ title: 'HAL 작업 엔진이 작업을 한 번에 하나씩만 실행(tick 이 execute 를 await)해 느린 작업이 큐 전체를 막고, module.deploy 부모가 자식 완료를 기다리면 자식이 실행될 수 없어 교착', file: 'lib/jobs.mjs (createJobEngine.start/tick) / hal/jobs.mjs (fanout 이 자식 완료를 폴링)', route: 'POST /ops/api/modules/:name/deploy', expected: '엔진은 running 수가 concurrency 미만이면 실행을 분리(detach)해 여러 작업을 동시에 진행해야 한다(부모가 자식을 기다려도 자식이 실행됨)', actual: dep, repro: '(1) Agent 가 수십 초 걸리는 domain.probe 가 queued 로 쌓이면 다른 모든 HAL 작업이 기다린다 (2) 큐가 비어도 모듈 배포는 부모 collect:running + 자식 queued 로 영원히 멈춘다(test/hal.test.mjs 는 pump() 로 tick 을 동시에 돌려 통과)', flows: [22, 30] });
else if (dep.status !== 'done') report.bug({ title: '모듈 설치가 ARIWEB_SRV_ROOT 를 무시하고 절대 경로(/srv/ariweb/<module>)에 쓰려 해 일반 계정·검증 환경에서 실패', file: 'hal/jobs.mjs (module.deploy.target: appDir 기본값) / agent/jobs/module.mjs', route: 'POST /ops/api/modules/:name/deploy', expected: 'Agent 가 payload.appDir 가 없거나 루트 override 가 있으면 roots.srv 아래에 설치', actual: dep, repro: 'ARIWEB_SRV_ROOT 를 둔 Agent 에 module.deploy → mkdir /srv/ariweb/hal 권한 오류', flows: [22, 30] });
const diag = dep.status === 'done' ? null : { stuckNow: await stuck(), halLog: env.hal.proc.lines.slice(-6), ctlAgentLog: ctl.proc.lines.slice(-4) };
report.flow(22, dep.status === 'done' ? 'verified_local' : 'failed', { steps: s.list, note: dep.status === 'done' ? 'HAL 우선 순서·릴리스 승인·모듈 설치' : 'HAL 우선 순서(hal_first 거부)·릴리스 승인·부트스트랩 잠김은 확인. 모듈 배포 작업은 완료되지 않음(진단 증거 참조)', evidence: { rel, hf, na, dep, diag } });
} catch (e) { report.flow(22, 'failed', { steps: s.list, note: e.message }); } }
// ---- 30 일부 서버 실패·대상별 결과
{ const s = steps(); try {
const d = await s.run('agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed)', async () => { const r = await env.ops.post('/ops/api/modules/agent/deploy', { version: '0.1.1', serverIds: [web.serverId, bk.serverId, 'srv_missing_e2e'], nonce: 'e2e-partial' }); const j = await waitOpsJob(env, r.json.job.id, 10000); const x = await env.ops.get(`/ops/api/jobs/${r.json.job.id}`); return { status: x.json?.job?.status, errorCode: x.json?.job?.errorCode, overall: x.json?.children?.overall, children: x.json?.children?.children?.map((c) => (c.subject?.serverId || '') + ':' + c.status + ':' + (c.error || '').slice(0, 80)), timedOut: !!j?.timedOut }; });
const h = await s.run('모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업', async () => { const r = await env.ops.post('/ops/api/modules/agent/health', { serverIds: [web.serverId] }); const j = await waitOpsJob(env, r.json.job.id, 5000); const ov = await env.ops.get('/ops/api/overview'); const rc = await env.ops.post('/ops/api/modules/reconcile', { module: 'agent' }); return { health: j?.job?.status || 'timeout', drift: ov.json.drift?.length, reconcile: rc.status, overviewComponents: ov.json.components?.map((c) => c.name + ':' + c.status) }; });
const rb = await s.run('되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부', async () => { const a = await env.ops.post('/ops/api/modules/agent/rollback', { serverIds: [web.serverId] }); const b = await env.ops.post('/ops/api/modules/agent/rollback', { serverIds: [web.serverId], confirmImpact: true, confirmIdentifiers: ['wrong'] }); return { step1: a.json?.error, step2: b.json?.error }; });
report.flow(30, 'failed', { steps: s.list, note: '대상별 자식 작업 생성·되돌리기 3단계 확인 거부는 확인. 일부 실패 요약·건강 확인·drift 조정은 작업 엔진 교착(흐름 22 결함)으로 완료되지 않음', blockedBy: 22, evidence: { d, h, rb } });
} catch (e) { report.flow(30, 'partial', { steps: s.list, note: e.message }); } }
// ---- 25 재해복구
{ const s = steps(); try {
const r0 = await s.run('설정 전 준비 상태(미설정 항목)', async () => (await env.ops.get('/ops/api/dr')).json.readiness);
const cfg = await s.run('RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정', async () => { const r = await env.ops.put('/ops/api/dr/config', { rto_minutes: 60, rpo_minutes: 30, snapshot_interval_minutes: 60, retention_count: 3, external_store: `${env.base}/dr-external` }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return r.json.config; });
const snap = await s.run('복구 사본 생성·검증·외부 보관(vault.key 제외)', async () => { const r = await env.ops.post('/ops/api/dr/snapshots'); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return { verify: r.json.snapshot.verify_state, external: !!r.json.snapshot.external_copied_at, sha: !!r.json.snapshot.sha256 }; });
const rs = await s.run('복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료', async () => { const sid = (await env.ops.get('/ops/api/dr')).json.snapshots[0].id; const rq = await env.ops.post('/ops/api/dr/restores', { snapshotId: sid, note: 'e2e 훈련' }); const id = rq.json.restore.id; const early = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: true, confirmText: '기존 제어 위치를 중지했습니다' }); await env.ops.post(`/ops/api/dr/restores/${id}/approve`); const noStop = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: false }); const st = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: true, confirmText: '기존 제어 위치를 중지했습니다' }); const inc = await env.ops.post(`/ops/api/dr/restores/${id}/finish`, { checks: { operatorLogin: true } }); const fin = await env.ops.post(`/ops/api/dr/restores/${id}/finish`, { checks: { operatorLogin: true, halControl: true, https: true, customerService: true } }); return { early: early.json?.error, noStop: noStop.json?.error, command: !!st.json?.command, incomplete: inc.json?.error, final: fin.json?.restore?.status }; });
const drill = await s.run('복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료', async () => { const sid = (await env.ops.get('/ops/api/dr')).json.snapshots[0].id; const d1 = await env.ops.post('/ops/api/dr/drills', { snapshotId: sid }); const f1 = await env.ops.post(`/ops/api/dr/drills/${d1.json.drill.id}/finish`, { steps: { hal: true, operatorLogin: true, vault: true, certBase: true, httpsCustomer: false }, measuredRtoMinutes: 20, measuredRpoMinutes: 10 }); const d2 = await env.ops.post('/ops/api/dr/drills', {}); const f2 = await env.ops.post(`/ops/api/dr/drills/${d2.json.drill.id}/finish`, { steps: { hal: true, operatorLogin: true, vault: true, certBase: true, httpsCustomer: true }, measuredRtoMinutes: 20, measuredRpoMinutes: 10 }); const rd = (await env.ops.get('/ops/api/dr')).json.readiness; return { first: f1.json?.drill?.result, second: f2.json?.drill?.result, ready: rd.ready, measured: rd.lastDrill ? { rto: rd.lastDrill.measured_rto_minutes, rpo: rd.lastDrill.measured_rpo_minutes } : null }; });
report.needsInfra('재해복구의 실제 복원(hal-restore.sh 로 다른 호스트에 HAL 재기동)·실제 운영자 로그인·HTTPS·고객 서비스 외부 확인은 실 서버가 필요하다(흐름 25 기록·훈련 절차만 로컬 확인)');
report.flow(25, 'partial', { steps: s.list, note: 'DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요', evidence: { r0, cfg, snap, rs, drill } });
} catch (e) { report.flow(25, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '재해복구 운영 API 흐름 실패', file: 'hal/dr.mjs', route: '/ops/api/dr/*', expected: '설정→사본→복원 기록→훈련', actual: e.message, repro: '80-hal.mjs 흐름 25', flows: [25] }); } }
// ---- 31 긴급 복구
{ const s = steps(); try {
const healthy = await s.run('HAL 정상 상태에서 선언 거부(hal_healthy)', async () => { const r = await env.ops.post('/ops/api/emergency', { incidentKey: 'INC-E2E', reason: 'HAL DB 손상으로 작업 엔진 사용 불가', scope: ['hal', 'hal_data'] }); return r.json?.error; });
const sess = await s.run('critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)', async () => { await env.ops.post('/ops/api/incidents', { key: 'INC-E2E', severity: 'critical', title: 'HAL 장애(e2e)', customerImpact: '없음' }); const d = await env.ops.post('/ops/api/emergency', { incidentKey: 'INC-E2E', reason: 'HAL DB 손상으로 작업 엔진 사용 불가', scope: ['hal', 'hal_data'] }); if (d.status !== 200) throw new Error(d.text.slice(0, 200)); const id = d.json.session.id; const noCred = await env.ops.post(`/ops/api/emergency/${id}/credential`); const wrongStep = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal'] }); await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 1, impactText: 'HAL 제어 평면 중단, 고객 서비스는 유지' }); const badIds = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal'] }); await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal_data', 'hal'] }); const badText = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 3, finalConfirm: '아무거나', revertible: true }); const ok = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 3, finalConfirm: '복구 실행 INC-E2E', revertible: true }); const cred = await env.ops.post(`/ops/api/emergency/${id}/credential`); return { id, noCred: noCred.json?.error, wrongStep: wrongStep.json?.error, badIds: badIds.json?.error, badText: badText.json?.error, approved: ok.json?.session?.status, approvals: ok.json?.session?.approvals?.length, credential: cred.json?.credential, expiresAt: cred.json?.expiresAt }; });
const perf = await s.run('일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료', async () => { const id = sess.id; const c = sess.credential; const forb = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'module.deploy' }); const oos = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'cert.reissue_internal' }); const bad = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: 'bad', action: 'hal.restart' }); const ok = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'vault.verify' }); const v = await env.ops.post(`/ops/api/emergency/${id}/verify`, { verify: { vaultOk: true } }); const after = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'vault.verify' }); const rc = await env.ops.post(`/ops/api/emergency/${id}/reconcile`); const cl = await env.ops.post(`/ops/api/emergency/${id}/close`, { note: 'e2e' }); return { forbidden: forb.json?.error, outOfScope: oos.json?.error, badCred: bad.json?.error, performed: ok.status, revoked: !!v.json?.session?.credential_revoked_at, afterRevoke: after.json?.error, reconcile: rc.status, closed: cl.json?.session?.status }; });
report.flow(31, 'partial', { steps: s.list, note: '정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인)', evidence: { healthy, sess: { ...sess, credential: undefined }, perf } });
} catch (e) { report.flow(31, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '긴급 복구 흐름 실패', file: 'hal/emergency.mjs', route: '/ops/api/emergency/*', expected: '3단계 승인·거부 규칙', actual: e.message, repro: '80-hal.mjs 흐름 31', flows: [31] }); } }
// ---- 29 HAL 장애 중 포털
{ const s = steps(); const alice = state.alice; try {
if (!alice || !state.site) throw new Error('고객 세션/사이트 없음');
const client = alice; const fresh = (await client.post('/api/sites', { name: 'HAL 장애 확인용', projectMode: 'new', buildMode: 'manual', deployMode: 'managed' })).json.site; await env.wm.addVersion(fresh.webmaker_project_id); const sid = fresh.id;
env.hal.proc.kill('SIGKILL'); await sleep(800);
const during = await s.run('HAL 중단 중: 기존 조회(사이트 목록·개요)는 제공, 배포 전 확인/배포는 정보 확인 지연·차단', async () => { const l = await client.get('/api/sites'); const ov = await client.get(`/api/sites/${sid}/overview`); const pc = await client.get(`/api/sites/${sid}/precheck`); const dep = await client.post(`/api/sites/${sid}/deploy`, {}); return { list: l.status, overview: ov.status, precheck: { status: pc.status, blocks: (pc.json?.precheck?.items || []).filter((i) => i.state === 'block').map((i) => i.id + ':' + (i.text || '').slice(0, 40)) }, deploy: { status: dep.status, error: dep.json?.error, message: dep.json?.message } }; });
const dash = await s.run('대시보드/알림에 중앙 상태 확인 지연 표시(가능하면)', async () => { const d = await client.get('/api/dashboard'); return { status: d.status, halState: d.json?.summary?.hal || d.json?.platform || d.json?.stale || null }; }, { soft: true });
const back = await s.run('HAL 재기동(같은 호스트의 죽은 인스턴스 임대 이어받기) → 서버·Agent 상태 복귀', async () => { const h = await env.hal.restart(); if (!h || h.timedOut) { const h2 = await env.hal.restart({ takeover: true }); if (!h2 || h2.timedOut) throw new Error('HAL 재기동 실패: ' + env.hal.proc.lines.slice(-4).join(' | ')); return { takeoverNeeded: true, bootstrap: h2.bootstrap?.complete }; } await env.loginOps(); const srv = await waitFor(async () => { const r = await env.ops.get('/ops/api/servers'); return r.json?.servers?.filter((x) => x.status === 'ready').length >= 4 ? r : null; }, { timeoutMs: 15000, intervalMs: 500 }); return { takeoverNeeded: false, bootstrap: h.bootstrap?.complete, readyServers: srv.json?.servers?.filter((x) => x.status === 'ready').length }; });
report.flow(29, 'verified_local', { steps: s.list, note: 'HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀', evidence: { during, dash, back } });
} catch (e) { report.flow(29, 'partial', { steps: s.list, note: e.message }); try { if (env.hal.proc.exited) await env.hal.restart({ takeover: true }).then(() => env.loginOps()); } catch (_) {} } }
// ---- 26 재시작 뒤 상태 유지
{ const s = steps(); const alice = state.alice; try {
const before = await s.run('재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록', async () => { const m = state.mailServiceId ? await alice.get(`/api/mail/services/${state.mailServiceId}/job`) : null; const j = await env.ops.get('/ops/api/jobs?limit=50'); return { mailJob: m?.json?.job ? { id: m.json.job.id, status: m.json.job.status } : null, halJobs: j.json?.jobs?.length }; });
const pr = await s.run('포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지', async () => { await env.portal.restart(); const me = await alice.get('/api/auth/me'); const m = state.mailServiceId ? await alice.get(`/api/mail/services/${state.mailServiceId}/job`) : null; return { sessionKept: !!me.json?.user, mailJob: m?.json?.job ? { id: m.json.job.id, status: m.json.job.status } : null, same: m?.json?.job?.id === before.mailJob?.id }; });
const hr = await s.run('HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지', async () => { await env.hal.restart(); const me = await env.ops.get('/ops/api/me'); const j = await env.ops.get('/ops/api/jobs?limit=50'); return { opsSessionKept: !!me.json?.operator, halJobs: j.json?.jobs?.length, sameCount: j.json?.jobs?.length === before.halJobs }; });
report.flow(26, pr.sessionKept && pr.same ? 'verified_local' : 'partial', { steps: s.list, note: '포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요', evidence: { before, pr, hr } });
} catch (e) { report.flow(26, 'partial', { steps: s.list, note: e.message }); } }
}
+61
View File
@@ -0,0 +1,61 @@
/**
* test/e2e/report.mjs — 흐름별 판정·증거를 모아 docs/verification/local-e2e-<날짜>.md / .json 으로 쓴다.
*
* 판정(verdict)
* verified_local 로컬 환경(실제 HAL·포털·Agent 프로세스 + 가짜 Webmaker/AriMail/메일)에서 끝까지 확인됨
* partial 일부 단계만 확인됨(나머지는 실제 인프라가 필요하거나 선행 흐름의 결함으로 막힘)
* not_verifiable_locally 로컬에서 확인할 수 없음(실 DNS·메일 왕복·실서버 등)
* failed 실제 결함으로 보이는 실패(재현 정보를 bugs 에 기록)
* 증거(evidence)에는 HTTP 상태·최종 화면 상태·작업 단계만 남기고 비밀값은 넣지 않는다(lib/redact).
*/
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { redact } from '../../lib/redact.mjs';
export const FLOWS = [
[1, '신규 고객 가입과 이메일 확인'], [2, '개인 사용자 그룹 생성과 다른 사용자 초대'], [3, '상품 주문, 결제 확인, 권리 활성화'], [4, '초안 사이트 생성'], [5, 'Webmaker에서 페이지 제작'],
[6, '관리형 배포'], [7, '도메인 연결과 HTTPS 발급'], [8, '외부 주소에서 사이트 확인'], [9, '사이트 정보와 접속·사용량 통계 확인'], [10, '사이트 대표 메일과 메일함 생성'],
[11, '외부 송신, 수신, 회신 확인'], [12, '독립 메일 서비스 생성'], [13, '백업 옵션 구매와 첫 백업 확인'], [14, '백업 복원과 외부 사이트 확인'], [15, '관리형 사이트 서버 이전과 외부 확인'],
[16, '외부 배포 사이트 생성과 배포'], [17, '사용량 경고와 한도 동작 확인'], [18, '상품 축소 보류와 정리 후 적용'], [19, '사이트 수 반환과 대표 메일의 독립 전환'], [20, '사이트 소유권 이전'],
[21, '중지, 해지, 보존 예정일 표시'], [22, 'HAL 우선 배포 후 HAL을 통한 전체 모듈 배포'], [23, '실패 작업의 재시도와 되돌리기'], [24, '운영자 승인, 감사 기록, 고객 알림의 일치'], [25, 'HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련'],
[26, '새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지'], [27, '마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공'], [28, '외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리'], [29, 'HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시'], [30, '모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정'],
[31, '긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구'], [32, 'Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료'], [33, '메일 서비스의 사이트 대표→독립 전환 후 항목 유지'], [34, '일시 중지·해지 예약·보존·삭제 단계의 표시 일치'],
];
const LABEL = { verified_local: '로컬 확인', partial: '부분 확인', not_verifiable_locally: '로컬 확인 불가', failed: '실패(결함)' };
export function createReport({ startedAt = new Date() } = {}) {
const flows = new Map(); const bugs = []; const infra = new Set(); const env = {};
const api = {
env,
/** 흐름 판정 기록(같은 번호를 다시 적으면 덮어쓴다 — 나중 판정이 더 정확) */
flow(no, verdict, { evidence = {}, note = '', blockedBy = null, steps = [] } = {}) { flows.set(no, { no, title: (FLOWS.find((f) => f[0] === no) || [])[1] || '', verdict, note, blockedBy, steps, evidence: redact(evidence) }); return api; },
/** 결함 기록: 정확한 재현 정보(파일·경로·기대·실제) */
bug({ title, file, route = '', expected, actual, repro = '', flows: fl = [] }) { if (!bugs.some((b) => b.title === title)) bugs.push({ title, file, route, expected, actual: redact(actual), repro, flows: fl }); return api; },
/** 실제 인프라가 필요한 항목 */
needsInfra(text) { infra.add(text); return api; },
bugs, infra, flows,
summary() { const c = { verified_local: 0, partial: 0, not_verifiable_locally: 0, failed: 0, unrecorded: 0 }; for (const [no] of FLOWS) { const f = flows.get(no); if (!f) c.unrecorded++; else c[f.verdict] = (c[f.verdict] || 0) + 1; } return c; },
write(dir, { date = startedAt } = {}) {
mkdirSync(dir, { recursive: true }); const stamp = date.toISOString().slice(0, 10); const sum = api.summary();
const rows = FLOWS.map(([no, title]) => { const f = flows.get(no); return f ? f : { no, title, verdict: 'unrecorded', note: '하네스가 이 흐름을 실행하지 못함(앞선 오류)', evidence: {}, steps: [] }; });
const json = { generatedAt: new Date().toISOString(), startedAt: startedAt.toISOString(), env, summary: sum, flows: rows, bugs, needsInfrastructure: [...infra] };
writeFileSync(join(dir, `local-e2e-${stamp}.json`), JSON.stringify(json, null, 1));
let md = `# 로컬 종단 간 검증 기록 — ${stamp}\n\n생성: ${json.generatedAt} · \`node --no-warnings test/e2e.test.mjs\` (test/e2e/)\n\n`;
md += `실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 ${env.agents || 0}대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다.\n\n`;
md += `## 요약\n\n| 판정 | 수 |\n|---|---|\n` + Object.entries(sum).map(([k, v]) => `| ${LABEL[k] || k} | ${v} |`).join('\n') + '\n\n';
md += `## 흐름별 판정(Release 0.1.1 완료 조건 34개)\n\n| # | 흐름 | 판정 | 확인 내용 / 비고 |\n|---|---|---|---|\n`;
for (const f of rows) md += `| ${f.no} | ${f.title} | ${LABEL[f.verdict] || f.verdict} | ${(f.note || '').replace(/\|/g, '/')}${f.blockedBy ? ` (선행 흐름 ${f.blockedBy} 에 막힘)` : ''} |\n`;
md += `\n## 발견한 결함(재현 정보)\n\n`; if (!bugs.length) md += '없음\n'; for (const b of bugs) md += `- **${b.title}** — \`${b.file}\`${b.route ? ` (${b.route})` : ''}\n - 기대: ${b.expected}\n - 실제: ${typeof b.actual === 'string' ? b.actual : JSON.stringify(b.actual)}\n - 재현: ${b.repro}${b.flows.length ? ` · 영향 흐름 ${b.flows.join(', ')}` : ''}\n`;
md += `\n## 실제 인프라가 필요한 항목\n\n`; for (const i of infra) md += `- ${i}\n`;
md += `\n## 흐름별 증거(요약)\n\n`; for (const f of rows) { if (!f.steps?.length && !Object.keys(f.evidence || {}).length) continue; md += `### ${f.no}. ${f.title}\n\n`; for (const s of f.steps || []) md += `- ${s.ok === false ? '✘' : '✔'} ${s.name}${s.detail ? ` — ${typeof s.detail === 'string' ? s.detail : JSON.stringify(s.detail).slice(0, 300)}` : ''}\n`; if (Object.keys(f.evidence || {}).length) md += '\n```json\n' + JSON.stringify(f.evidence, null, 1).slice(0, 1800) + '\n```\n'; md += '\n'; }
writeFileSync(join(dir, `local-e2e-${stamp}.md`), md);
return { md: join(dir, `local-e2e-${stamp}.md`), json: join(dir, `local-e2e-${stamp}.json`), summary: sum };
},
};
return api;
}
/** 흐름 안에서 단계별 증거를 모으는 도우미: const s = steps(); await s.run('이름', fn) → 결과 기록(예외는 ok:false 로 남기고 다시 던진다) */
export function steps() {
const list = [];
return { list, async run(name, fn, { soft = false } = {}) { try { const r = await fn(); list.push({ name, ok: true, detail: r === undefined ? undefined : (typeof r === 'string' ? r : redact(r)) }); return r; } catch (e) { list.push({ name, ok: false, detail: e && e.message ? String(e.message).slice(0, 300) : String(e) }); if (soft) return null; throw e; } }, note(name, detail) { list.push({ name, ok: true, detail }); } };
}
+86
View File
@@ -0,0 +1,86 @@
/**
* 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); } }