Files
AriWeb-Suite/test/hal-ui.test.mjs

107 lines
14 KiB
JavaScript

/** 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('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(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: '기존 사이트 <A>', ownerId: 'owner-a', status: '<idle>', 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('기존 사이트 &lt;A&gt;') && !r.text.includes('기존 사이트 <A>') && r.text.includes('&lt;idle&gt;') && !r.text.includes('<idle>'));
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<script>'], errorsTruncated: true, sites: [] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('인벤토리 스캔 오류 1건') && r.text.includes('EACCES&lt;script&gt;') && !r.text.includes('EACCES<script>'));
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 'bad', returned: {}, errors: '<script>', sites: [null, { id: '<id>', domains: '<img onerror=1>', files: {} }] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); eq(r.status, 200); ok(r.text.includes('&lt;id&gt;') && !r.text.includes('<img onerror=1>'), 'null 등 malformed agent facts도 렌더러를 깨거나 HTML로 실행하지 않음');
r = await op.as('GET', '/ops/releases'); ok(r.text.includes('HAL 을 먼저 배포') && r.text.includes('abcdef123456') && r.text.includes('테스트 통과') && r.text.includes('승인 대기'));
// 작업: 부모 + 자식(하나 실패) → 부분 실패 표시
h.d.jobs.register('ui.demo', { title: '데모', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { await ctx.step('a', () => { if (job.subject.fail) throw new Error('대상 실패'); return 1; }); } });
const p = h.d.jobs.enqueue({ kind: 'ui.demo', initialStatus: 'awaiting_external', label: '부모' }).job; h.d.jobs.enqueue({ kind: 'ui.demo', parentId: p.id, label: '대상1' }); h.d.jobs.enqueue({ kind: 'ui.demo', parentId: p.id, label: '대상2', subject: { fail: true } }); await h.d.jobs.drain();
r = await op.as('GET', '/ops/jobs'); ok(r.text.includes('부모') && r.text.includes('대상2'));
r = await op.as('GET', `/ops/jobs/${p.id}`); ok(r.text.includes('부분 실패') && r.text.includes('대상 실패'), '자식 실패는 부분 실패로');
r = await op.as('GET', '/ops/jobs?status=needs_user,failed'); ok(r.text.includes('대상2') && !r.text.includes('>대상1<'));
// 사건·감사·복구·계정
await op.as('POST', '/ops/api/incidents', { key: 'k1', title: '메일 지연', severity: 'warning', customerImpact: '수신 지연' });
r = await op.as('GET', '/ops/incidents'); ok(r.text.includes('메일 지연') && r.text.includes('사용자 경로 확인 후'));
r = await op.as('GET', '/ops/audit?action=ops.'); ok(r.text.includes('ops.') || r.text.includes('기록이 없습니다'));
r = await op.as('GET', '/ops/recovery'); ok(r.text.includes('긴급 복구 선언') && r.text.includes('정식 운영 준비 전') && r.text.includes('기존 제어 위치 중지 확인'));
r = await op.as('GET', '/ops/account'); ok(r.text.includes('접근권') && r.text.includes('운영자 추가') && r.text.includes('비밀번호만'));
r = await op.as('GET', '/ops/'); ok(r.text.includes('목표·실제 차이') && r.text.includes('0.1.0'), '운영 현황에 버전 차이');
const o = overviewLocal(h.d); eq(o.drift.length, 1); ok(o.components.find((c) => c.name === 'portal').status === 'ok');
});
await test('중계 실패 → 정보 지연(정상으로 판정하지 않음), 재인증 필요·권한 없음 표시, 포털 미연동 503', async () => {
const h = makeHal(); const op = await loginOp(h);
let r = await op.as('GET', '/ops/api/portal/commerce/orders'); eq(r.status, 503); eq(r.json.error, 'portal_not_linked');
h.d.vault.set('service:hal', 'hal-secret-example'); r = await op.as('GET', '/ops/api/portal/commerce/orders'); eq(r.status, 503); eq(r.json.error, 'portal_unreachable');
const html = R.relayError(r.json); ok(html.includes('정보 지연') && !html.includes('정상')); ok(R.relayError({ code: 'reauth_required' }).includes('재인증')); ok(R.relayError({ code: 'unauthenticated' }).includes('권한 없음'));
ok(R.pendingApprovals({ orders: 1, restores: 0, relocations: 2, transfers: 0, partial: ['/transfer'] }).includes('부분 실패'));
// 고객 화면 렌더러는 빈 데이터도 상태 패널로
ok(R.customerView({ group: { id: 'g1', name: '그룹', kind: 'team' } }).includes('사이트가 없습니다')); ok(R.ordersView({ orders: [] }).includes('비어 있음'));
ok(R.serversTable([]).includes('등록된 서버가 없습니다')); ok(R.metricsSvg([]).includes('비어 있음')); ok(R.metricsSvg([{ at: '2026-10-01T00:00:00Z', cpu: 5 }, { at: '2026-10-02T00:00:00Z', cpu: 50 }], 'cpu').includes('<svg'));
});
await test('긴급 복구·DR 화면: 승인 단계·자격 미발급 안내·훈련 전 준비 전 표시; 변경 이력 파서', async () => {
const h = makeHal(); const op = await loginOp(h);
h.d.store.run('UPDATE bootstrap_state SET hal_running_version=NULL'); // HAL 비정상 상태로 선언 허용
let s; try { s = emergency.declare(h.d, { incidentKey: 'INC-1', reason: 'HAL 정지', scope: ['hal'], by: 'ozops', force: true }); } catch (e) { s = null; }
const r = await op.as('GET', '/ops/recovery'); ok(r.text.includes('허용 작업') && r.text.includes('일반 고객 서비스 배포는 금지'));
if (s) { ok(r.text.includes('INC-1') && r.text.includes('1단계 승인') && r.text.includes('미발급')); }
const cl = changelog(); ok(typeof cl === 'object');
const v = R.drView({ readiness: dr.readiness(h.d), snapshots: [], restores: [{ id: 'drr_1', snapshot_id: 'x', status: 'requested' }], drills: [] }); ok(v.includes('미확인') && v.includes('승인') && v.includes('미설정'));
});
await test('포털 고객 지원 API: 검색·그룹 보기·대신 변경(전후·사유·승인자·고객 감사)·허용 밖 항목 거부·일시 접근 발급·회수', async () => {
const deps = await makeDeps({ opsAuthBypass: true }); const who = seedGroup(deps, { email: 'cust@example.test', name: '고객사' });
const app = await appFor(deps, ['ops', 'auditui'], who);
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', 's_1', who.group.id, '홈페이지', 'live', new Date().toISOString(), new Date().toISOString());
let r = await call(app, 'GET', '/internal/ops/customers?q=cust@'); eq(r.status, 200); eq(r.json.groups[0].id, who.group.id); eq(r.json.users[0].email, 'cust@example.test'); ok(!JSON.stringify(r.json).includes('pass_hash'));
r = await call(app, 'GET', `/internal/ops/customers/${who.group.id}`); eq(r.status, 200); eq(r.json.sites.length, 1); eq(r.json.members.length, 1); ok(Array.isArray(r.json.usage) && r.json.changeable.includes('site.maintenance'));
r = await call(app, 'POST', `/internal/ops/customers/${who.group.id}/change`, { field: 'site.maintenance', subjectId: 's_1', value: true, reason: '고객 요청(전화)', approver: 'hera', _operator: { login: 'ozops' } }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.change.before, 0); eq(r.json.change.after, 1); eq(r.json.change.operator, 'ozops');
eq(deps.store.get('SELECT maintenance FROM sites WHERE id=?', 's_1').maintenance, 1);
const au = deps.store.get('SELECT * FROM audit_log WHERE action=? AND group_id=?', 'ops.change.site.maintenance', who.group.id); ok(au && au.immutable === 1 && au.detail_json.includes('고객 요청'), '고객 감사 기록');
r = await call(app, 'POST', `/internal/ops/customers/${who.group.id}/change`, { field: 'user.password', value: 'x', reason: 'r' }); eq(r.status, 400); eq(r.json.error, 'field_not_changeable');
r = await call(app, 'POST', `/internal/ops/customers/${who.group.id}/change`, { field: 'site.name', subjectId: 's_other', value: 'x', reason: '다른 그룹 대상 시험' }); eq(r.status, 404);
r = await call(app, 'POST', '/internal/ops/support-access', { groupId: who.group.id, targetType: 'site', targetId: 's_1', reason: '배포 실패 조사', hours: 2, scope: ['view'], _operator: { login: 'ozops' } }); eq(r.status, 200); eq(r.json.grant.state, 'active'); eq(r.json.grant.approved_by, 'ozops');
r = await call(app, 'POST', `/internal/ops/support-access/${r.json.grant.id}/revoke`, {}); eq(r.json.grant.state, 'revoked');
r = await call(app, 'GET', `/internal/ops/customers/${who.group.id}`); eq(r.json.supportAccess.length, 1); eq(r.json.changes.length, 1);
// 서명 없이(바이패스 끔) 부르면 401
deps.opsAuthBypass = false; r = await call(app, 'GET', '/internal/ops/customers'); eq(r.status, 401);
});
done();