/** test/hal-ui.test.mjs — 운영자 콘솔 화면 회귀 테스트: 네트워크 관문·운영자 세션 필수(고객 쿠키 거부)·주요 화면 렌더·중계 실패 → 정보 지연·포털 고객 지원 API. */
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import { buildApp } from '../hal/app.mjs';
import * as core from '../hal/core.mjs';
import * as ops from '../hal/ops.mjs';
import * as emergency from '../hal/emergency.mjs';
import * as dr from '../hal/dr.mjs';
import * as R from '../hal/ui/render.mjs';
import { changelog, overviewLocal } from '../hal/ui.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
function makeHal(over = {}) {
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); }, ...over };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });
return { d, app: buildApp(d), dataDir };
}
/** 운영자 계정 + 로그인 세션 */
async function loginOp(h) { ops.createAccount(h.d, { login: 'ozops', password: 'password-example-1', displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: 'password-example-1' } }); ok(r.json.ok, JSON.stringify(r.json)); return { cookie: r.cookies.aw_ops, csrf: r.json.csrf, as: (method, path, body, extra = {}) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops, ...(extra.cookies || {}) }, headers: { 'x-csrf': r.json.csrf, ...(extra.headers || {}) }, ip: extra.ip }) }; }
await test('HAL 루트 주소는 운영자 콘솔 기본 경로로 안내한다', async () => {
const h = makeHal();
const r = await invoke(h.app, 'GET', '/');
eq(r.status, 302, '루트 주소는 404가 아니라 운영자 콘솔로 이동');
eq(r.headers.Location, '/ops/', '운영자 콘솔의 표준 경로로 이동');
});
await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠키 → 로그인 화면, 운영자 세션 → 콘솔(메뉴 14개 + 계정)', async () => {
const h = makeHal();
let r = await invoke(h.app, 'GET', '/ops/', { ip: '203.0.113.9' }); eq(r.status, 403); eq(r.json.error, 'access_denied');
r = await invoke(h.app, 'GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('HAL 최초 설정'), '계정 없으면 최초 설정 화면');
const op = await loginOp(h);
r = await invoke(h.app, 'GET', '/ops/servers', { cookies: { aw_session: 'customer-cookie-example' } }); eq(r.status, 200); ok(r.text.includes('운영자 로그인') && !r.text.includes('서버 등록'), '고객 세션 쿠키로는 로그인 화면만');
r = await op.as('GET', '/ops/'); eq(r.status, 200); for (const m of ['운영 현황', '고객과 사용자 그룹', '주문과 결제 승인', '상품과 정책', '사이트와 배포', '메일 서비스', '도메인과 인증서', '서버와 용량', '백업과 복원', '서버 이전', '배포 릴리스', '운영 작업', '장애와 알림', '감사 기록', '플랫폼 복구']) ok(r.text.includes(m), '메뉴 ' + m);
ok(r.text.includes('부트스트랩 진행 중'), '부트스트랩 상태 배너'); ok(r.text.includes('data-page="overview"'));
// 로그인 화면은 고객 로그인과 분리된 문구
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
});
await test('부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약(complete·halRunningVersion·firstAgent)만 본다 — 완료 뒤에는 경고가 사라지고, 미완료 때는 실행 버전·첫 Agent 상태가 정확하다', async () => {
const h = makeHal(); const op = await loginOp(h);
// 계약 고정: selfHealth 는 camelCase 만 내고 옛 snake_case 열 이름은 없다(배너가 어떤 필드를 읽는지 못 박는다)
const b0 = core.selfHealth(h.d).bootstrap; eq(b0.complete, false); ok(!('completed_at' in b0) && !('hal_running_version' in b0) && !('first_agent_id' in b0), 'selfHealth.bootstrap 에 snake_case 필드가 없다');
// 1) 아무것도 안 된 상태: 실행 버전 확인 전 · 첫 Agent 미등록
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 확인 전, HAL 서버의 Node Agent 미설치)'), '초기 상태 문구');
// 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록
core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.1.1' });
r = await op.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 0.1.1, HAL 서버의 Node Agent 미설치)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
// 3) 첫 Agent 등록·승인 → 부트스트랩 완료(core.approveServer → maybeCompleteBootstrap). 경고는 사라져야 한다
const s = core.createServer(h.d, { name: '웹1', role: 'web' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok, JSON.stringify(reg.json));
core.approveServer(h.d, s.id, 'ozops');
const b1 = core.selfHealth(h.d).bootstrap; eq(b1.complete, true); eq(b1.halRunningVersion, '0.1.1'); eq(b1.firstAgent, reg.json.agentId); eq(b1.manualInstallLocked, true);
r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes('부트스트랩 미완료'), `완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
r = await op.as('GET', '/ops/servers'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 다른 화면(서버)에도 경고 없음');
r = await op.as('GET', '/ops/releases'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 배포 릴리스 화면에도 경고 없음'); ok(r.text.includes('HAL 실행 버전: 0.1.1') && r.text.includes('수동 설치 잠김'), '릴리스 화면의 실행 버전·수동 설치 잠김은 실제 bootstrap 행 값');
// 4) 첫 Agent 는 등록됐지만 HAL 실행 버전이 없는 새 HAL(재해복구 등): 실행 버전 확인 전 · 첫 Agent 등록됨
const h2 = makeHal(); const op2 = await loginOp(h2); const s2 = core.createServer(h2.d, { name: '웹2', role: 'web' }); const t2 = core.issueRegistrationToken(h2.d, { serverId: s2.id });
const reg2 = await invoke(h2.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'web2' }, version: '0.1.1' } }); ok(reg2.json.ok); core.approveServer(h2.d, s2.id, 'ozops');
eq(core.selfHealth(h2.d).bootstrap.complete, false); eq(core.selfHealth(h2.d).bootstrap.firstAgent, reg2.json.agentId);
r = await op2.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 확인 전, HAL 서버의 Node Agent 등록됨 — 승인 대기)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
});
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
const h = makeHal(); const op = await loginOp(h);
const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1', webmakerInventory: { total: 1, returned: 1, truncated: false, sites: [{ id: 'a'.repeat(32), title: '기존 사이트 ', ownerId: 'owner-a', status: '', domains: ['a.example.test'], deploymentState: 'deployed', bytes: 1234, files: 3, updatedAt: '2026-10-27T00:00:00Z', metadata: 'ok' }] } }, version: '0.1.1' } }); ok(reg.json.ok);
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=?, metrics_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 10e9, diskTotalBytes: 100e9, sitesHosted: 3, reservedBytes: 1e9 }), JSON.stringify({ cpu: 12, memUsed: 50, memTotal: 100, netIn: 1000, netOut: 2000 }), s.id);
core.approveServer(h.d, s.id, 'ozops');
h.d.store.run('INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', 'portal', s.id, '0.1.1', '0.1.0', 'ok', 1, new Date().toISOString(), new Date().toISOString());
core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abcdef1234567890', builtAt: new Date().toISOString(), artifacts: { hal: { file: 'hal.tgz', sha256: 'sha256:x', size: 1 } }, checks: { testsPassed: true } });
let r = await op.as('GET', '/ops/servers'); eq(r.status, 200); ok(r.text.includes('웹1') && r.text.includes('배치 가능') && r.text.includes('0.1.0') && r.text.includes('0.1.1'), '서버 표에 상태·모듈 차이');
r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"')); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('owner-a') && r.text.includes('a.example.test') && r.text.includes('기존 사이트 <A>') && !r.text.includes('기존 사이트 ') && r.text.includes('<idle>') && !r.text.includes(''));
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 0, returned: 0, truncated: false, errors: ['sites:EACCES