HAL 0.4.1: 서버 등록 TLS 수정·"이미 등록된 서버" 안내, HAL 배포가 HAL 서버의 Node Agent·HawkEye 를 함께 설치해 부트스트랩을 닫음

- hal/tls.mjs: 공개 주소 TLS 신뢰 확인(시스템 CA → 실패면 체인·공개키 핀), 신뢰 앵커(체인 자체 서명 / <data>/tls/hal-ca.pem), 10분 캐시, 핀 설치 명령(curl -k --pinnedpubkey … --hal-pin). 공개 경로 GET /agent/v1/hal-ca.pem · /agent/v1/tls, CLI tls-probe
- deploy/agent-install.sh: --hal-pin(핀으로 CA 받기)·--ca·--insecure, curl --cacert / Node NODE_EXTRA_CA_CERTS / register --ca / 유닛 Environment, 연결 실패 원인·조치 한 줄, 이미 등록된 서버 안내
- agent/halca.mjs + agent/index.mjs·config.mjs: caFile 런타임 적용(tls.setDefaultCACertificates), fetch 실패를 읽을 수 있는 한 줄로(종료 1), 이미 등록(종료 2, --force), HAL 거부(종료 3), heartbeat observability → hawkeye
- core.registerAgent: 같은 서버의 두 번째 토큰 409 server_already_registered / 쓴 토큰 409 token_used("이미 등록된 서버"), 거부 토큰 미소비, 재등록 허용(replace: 기존 Agent 해지, 감사 agent.replace), 마이그레이션 hal_0011_token_replace, API/CLI --replace --json(재인증), ops.js 재등록 확인·핀 안내
- deploy/hal-host-agent.sh(server-create --profile hal → server-token --json → agent-install loopback → server-approve), bootstrap-hal.sh 6/7 단계(--no-agent), CLI server-create·server-approve, SERVER_PROFILES.hal
- core.deploymentStatus/selfHealth.deployment, 배너 "부트스트랩 완료 · 기능 단위 서버 배포 대기: …" / "진행 중", 배포 릴리스 화면 문구, heartbeat canonicalModuleName(0.3.17 잔여 결함)
- 개발 지시 v0.4.1(Oz)·VERSION_HISTORY 0.4.1·README·docs/HAL-AGENT §6·RUNBOOK §11·AI 스킬, 도입 모듈 변경 없음(Webmaker 1.9.0·AriMail 1.1.0)
- test/hal-041.test.mjs RED → GREEN 7/7(실제 자체 서명 TLS 픽스처·별도 프로세스 CLI), 기존 배너·프로필·버전 테스트 갱신, 전체 32파일 통과, 로컬 E2E 보고서 2026-11-06

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-11-06 08:40:09 +00:00
co-authored by Claude Fable 5.1
parent 0f5e01a4cb
commit 2b31543a86
34 changed files with 4639 additions and 72 deletions
+2 -2
View File
@@ -91,7 +91,7 @@ await test('4·6) 배포 릴리스: HAL 은 배포 대상 아님, HAL 먼저 규
await test('2) 역할별 통합 배포: 프로필대로 모듈을 순서대로 배포하고 서버에 프로필·역할·슬롯을 기록, 포털은 하나만(이미 있으면 거부) + 포털 이전 작업, 슬롯 기반 배치', async () => {
const h = makeHal(); const op = await loginOp(h); const s1 = await addServer(h, { name: '새서버', role: 'web' }); const s2 = await addServer(h, { name: '컨트롤', role: 'control' }); bootstrapDone(h.d, '0.3.17'); addRelease(h, '0.3.17', ['agent', 'portal', 'webmaker', 'arimail']);
ok(SERVER_PROFILES.webmaker && SERVER_PROFILES.webmaker.modules.includes('webmaker') && SERVER_PROFILES.webmaker.modules.includes('hawkeye') && SERVER_PROFILES.webmaker.modules[0] === 'agent', JSON.stringify(SERVER_PROFILES.webmaker));
let r = await op.as('GET', '/ops/api/provision/profiles'); eq(r.status, 200); ok(r.json.profiles.map((p) => p.id).sort().join(',') === 'arimail,backup,portal,webhosting,webmaker');
let r = await op.as('GET', '/ops/api/provision/profiles'); eq(r.status, 200); ok(r.json.profiles.map((p) => p.id).sort().join(',') === 'arimail,backup,hal,portal,webhosting,webmaker'); // [0.4.1] hal 프로필(HAL 서버: agent·hawkeye)
// webmaker 프로필을 새서버에: agent → webmaker → hawkeye 순서로 자식 작업, 서버 profile·role·slots 기록
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'webmaker', version: '0.3.17' }); eq(r.status, 200, JSON.stringify(r.json)); const pj = r.json.job;
const seen = []; await runAgents(h, [s1, s2], (job) => { seen.push(job.payload.module); return { status: 'done', result: { module: job.payload.module, actualVersion: job.payload.version || '0.1.1', health: { ok: true }, active: true } }; });
@@ -141,7 +141,7 @@ await test('3·5·7) 화면: 부분 갱신 diff(순수)·ops.js 정적 계약,
});
await test('버전·문서(0.3.17)', async () => {
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.17.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.17'));
ok(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').includes('HawkEye') && !/observability\b(?! 는 옛 이름)/.test(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').replace('옛 이름 observability', '')), '스킬 문서도 HawkEye');
});
+1 -1
View File
@@ -150,7 +150,7 @@ await test('5) 화면·AI 도구·정적 계약', async () => {
});
await test('버전·문서·모듈 대응(0.4.0 · Webmaker 1.9.0 · AriMail 1.1.0)', async () => {
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.0.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.4.0')); ok(readFileSync(join(ROOT, 'README.md'), 'utf8').includes('0.4.0'));
ok(existsSync(join(ROOT, 'docs', 'HAL-SETTINGS.md')));
const wm = JSON.parse(readFileSync(join(ROOT, 'modules', 'webmaker', 'package.json'), 'utf8')); eq(wm.version, '1.9.0'); eq(wm.ariwebSuite && wm.ariwebSuite.halCompat, '0.4.0');
+192
View File
@@ -0,0 +1,192 @@
/**
* test/hal-041.test.mjs — Ariweb Suite 0.4.1(개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.1.md`, Oz 2026-11-06): 서버 등록 TLS·이미 등록 메시지 + HAL 배포가 Agent·HawkEye 를 함께 설치해 부트스트랩을 닫는다.
*
* 계약(요약)
* 1) hal/tls.mjs: HAL 공개 주소가 자체 서명·사설 CA 면 trusted=false·공개키 핀(curl --pinnedpubkey 형식)·신뢰 앵커 PEM. http 는 trusted. 설치 명령은 핀(-k --pinnedpubkey / --hal-pin)을 붙인다.
* 공개 경로 GET /agent/v1/hal-ca.pem(신뢰 앵커) · GET /agent/v1/tls(상태) — 무서명.
* 2) 이미 등록된 서버: 같은 서버의 두 번째 토큰 → 409 server_already_registered("이미 등록된 서버"), 쓴 토큰 재사용 → 409 token_used(같은 문구), 재등록 허용 토큰(replace) → 기존 Agent 해지·새 Agent. API 의 replace 는 재인증.
* 3) Agent CLI register: CA 없이 자체 서명 HAL → 스택 대신 한 줄 원인·조치(종료 1), --ca → HAL 응답(bad_token), 등록 뒤 재실행 → "이미 등록된 서버"(종료 2). agent.json 에 caFile, status 는 런타임 CA 로 연결.
* 4) 부트스트랩: HAL 서버 자신(control, 프로필 hal) 의 Agent 등록·승인으로 완료(CLI server-create/server-token --json/server-approve), 배너는 "부트스트랩 미완료" 대신 기능 단위 서버 배포 대기. heartbeat 의 observability 는 hawkeye 로.
* 5) 스크립트·문서·버전: agent-install.sh(--hal-pin·--ca·NODE_EXTRA_CA_CERTS·이미 등록), hal-host-agent.sh, bootstrap-hal.sh 가 부름, 0.4.1.
*/
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { signHeaders } from '../lib/serviceauth.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 { probePublicTls, installCommand, caBundle, spkiPin, ensureTlsInfo } from '../hal/tls.mjs';
import { SERVER_PROFILES, MODULE_GUIDES } from '../hal/modules/index.mjs';
import * as R from '../hal/ui/render.mjs';
import { applyHalCa, friendlyFetchError, splitPem } from '../agent/halca.mjs';
import { loadAgentConfig } from '../agent/config.mjs';
import https from 'node:https';
import { X509Certificate } from 'node:crypto';
import { execFileSync, spawnSync, spawn } from 'node:child_process';
import { mkdirSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const rnd = (p) => `${p}-${randomBytes(4).toString('hex')}`;
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: 4000, fanoutTimeoutMs: 6000, values: {}, set(p) { Object.assign(this, p); }, ...over };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'ok', json: async () => ({ ok: true }) }) }); d.jobsRunning = true;
return { d, app: buildApp(d), dataDir };
}
async function loginOp(h) { const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok); return { pw, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
const sig = (keyId, secret, method, path, body) => Object.fromEntries(Object.entries(signHeaders(keyId, secret, method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]));
const agentCall = (app, id, secret, method, path, body) => invoke(app, method, path, { body, headers: sig(`agent:${id}`, secret, method, path, body) });
/** 자체 서명 인증서(hal.local.test) + HTTPS 서버. handler 가 없으면 HAL 앱(invoke)으로 중계한다 — 실제 TLS 핸드셰이크·fetch 경로를 시험한다. */
async function tlsFixture(h = null) {
const dir = tmpDir(); const key = join(dir, 'key.pem'); const cert = join(dir, 'cert.pem');
execFileSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', key, '-out', cert, '-days', '2', '-subj', '/CN=hal.local.test', '-addext', 'subjectAltName=DNS:hal.local.test,DNS:localhost,IP:127.0.0.1'], { stdio: 'ignore' });
const srv = https.createServer({ key: readFileSync(key), cert: readFileSync(cert) }, async (req, res) => {
if (!h) { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ ok: true })); }
let text = ''; for await (const c of req) text += c; const r = await invoke(h.app, req.method, req.url, { body: text ? JSON.parse(text) : undefined, headers: Object.fromEntries(Object.entries(req.headers).filter(([k]) => k.startsWith('x-') || k === 'content-type')) });
res.writeHead(r.status || 200, { 'content-type': 'application/json', ...(r.headers || {}) }); res.end(r.text);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port;
return { dir, cert, key, port, url: `https://127.0.0.1:${port}`, close: () => new Promise((r) => srv.close(r)) };
}
/** Agent CLI 를 별도 프로세스로(비동기 — 같은 프로세스의 HTTPS 픽스처가 응답해야 하므로 spawnSync 를 쓰면 안 된다) */
const agentCli = (dataDir, args, extraEnv = {}) => new Promise((resolve) => { const p = spawn(process.execPath, ['--no-warnings', join(ROOT, 'agent', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_AGENT_DATA: dataDir, ARIWEB_AGENT_STUB_CMDS: '1', ...extraEnv } }); let stdout = ''; let stderr = ''; p.stdout.on('data', (c) => { stdout += c; }); p.stderr.on('data', (c) => { stderr += c; }); const t = setTimeout(() => p.kill('SIGKILL'), 40000); p.on('close', (status) => { clearTimeout(t); resolve({ status, stdout, stderr }); }); });
// HAL CLI: 공개 주소는 환경(HAL_PUBLIC_URL)으로 고정해 실제 hal.ariweb.net 에 TLS 확인을 보내지 않는다
const halCli = (dataDir, args, extraEnv = {}) => spawnSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0', HAL_PUBLIC_URL: 'http://127.0.0.1:1', ...extraEnv }, encoding: 'utf8', timeout: 40000 });
await test('[요구 1] TLS 확인: 자체 서명 HAL 주소 → trusted=false·공개키 핀·신뢰 앵커 PEM / http 주소 → 신뢰 / 접속 불가 → null', async () => {
const fx = await tlsFixture();
try {
const info = await probePublicTls(fx.url); eq(info.trusted, false); eq(info.reason, 'DEPTH_ZERO_SELF_SIGNED_CERT'); ok(/^sha256\/\/[A-Za-z0-9+/]+=*$/.test(info.pin), '핀 형식 ' + info.pin);
eq(info.pin, spkiPin(new X509Certificate(readFileSync(fx.cert))), '핀 = 서버 인증서 SPKI sha256'); eq(info.ca.length, 1); eq(info.caSource, 'chain'); ok(caBundle(info).includes('BEGIN CERTIFICATE')); ok(info.subject.includes('hal.local.test'));
// 운영자 지정 CA 파일(<data>/tls/hal-ca.pem)이 있으면 체인 대신 그 파일 — 그리고 그 CA 로 검증되면 trusted=true
const dataDir = tmpDir(); mkdirSync(join(dataDir, 'tls')); writeFileSync(join(dataDir, 'tls', 'hal-ca.pem'), readFileSync(fx.cert)); const info2 = await probePublicTls(fx.url, { dataDir }); eq(info2.trusted, true); eq(info2.reason, 'trusted_by_file_ca'); eq(info2.caSource, 'file'); eq(info2.ca.length, 1, '파일 CA 를 그대로 제공(다른 서버가 받아 쓴다)');
const plain = await probePublicTls('http://127.0.0.1:1'); eq(plain.trusted, true); eq(plain.reason, 'plain_http'); eq(plain.pin, null);
const dead = await probePublicTls('https://127.0.0.1:1', { timeoutMs: 1500 }); eq(dead.trusted, null); ok(dead.error.includes('접속할 수 없습니다'));
eq((await probePublicTls('not a url')).reason, 'bad_url');
} finally { await fx.close(); }
});
await test('[요구 1] 설치 명령·공개 경로: 신뢰되지 않으면 -k --pinnedpubkey 와 --hal-pin, 신뢰되면 핀 없음; /agent/v1/hal-ca.pem·/agent/v1/tls 는 무서명 공개', async () => {
const fx = await tlsFixture(); const h = makeHal({ publicUrl: fx.url });
try {
const info = await ensureTlsInfo(h.d); eq(info.trusted, false); eq(h.d.tlsInfo.pin, info.pin); ok(await ensureTlsInfo(h.d) === info, '10분 안에는 캐시');
const srv = core.createServer(h.d, { name: 'ws2', role: 'web' }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
ok(t.installCommand.startsWith(`curl -fsSL -k --pinnedpubkey '${info.pin}' ${fx.url}/agent/v1/install.sh | bash -s -- --hal ${fx.url} --token ${t.token} --hal-pin '${info.pin}'`), t.installCommand); eq(t.tls.trusted, false); eq(t.tls.caAvailable, true); eq(t.existingAgent, null);
const plain = installCommand('https://hal.ariweb.net', 'TOK', { tlsInfo: { trusted: true, pin: 'sha256//x' } }); eq(plain.installCommand, 'curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token TOK'); eq(installCommand('https://h', 'T', { tlsInfo: null }).tls, null);
eq(installCommand('https://h', 'T', { kind: 'external', siteId: 's1', tlsInfo: { trusted: false, pin: 'sha256//p' } }).installCommand, "curl -fsSL -k --pinnedpubkey 'sha256//p' https://h/agent/v1/install.sh | bash -s -- --hal https://h --token T --external --site s1 --hal-pin 'sha256//p'");
let r = await invoke(h.app, 'GET', '/agent/v1/hal-ca.pem'); eq(r.status, 200); ok(r.text.includes('BEGIN CERTIFICATE'), '신뢰 앵커 PEM'); ok(!r.text.includes('PRIVATE'), '개인키 없음');
r = await invoke(h.app, 'GET', '/agent/v1/tls'); eq(r.status, 200); eq(r.json.tls.trusted, false); eq(r.json.tls.pin, info.pin); eq(r.json.tls.caAvailable, true); ok(!JSON.stringify(r.json).includes('PRIVATE'));
// 신뢰되는(http) 주소면 CA 경로는 404(필요 없음)
const h2 = makeHal({ publicUrl: 'http://127.0.0.1:1' }); r = await invoke(h2.app, 'GET', '/agent/v1/hal-ca.pem'); eq(r.status, 404); eq(r.json.error, 'no_private_ca');
// 운영자 API: 토큰 발급 응답에 tls·installCommand(핀)·existingAgent
const op = await loginOp(h); r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, {}); eq(r.status, 200); ok(r.json.installCommand.includes('--hal-pin')); eq(r.json.tls.trusted, false);
} finally { await fx.close(); }
});
await test('[요구 1] 이미 등록된 서버: 두 번째 토큰 → 409 server_already_registered, 쓴 토큰 → 409 token_used(이미 등록된 서버 문구), 재등록 허용 토큰 → 기존 Agent 해지·새 Agent, API replace 는 재인증', async () => {
const h = makeHal(); const srv = core.createServer(h.d, { name: 'WS1', role: 'web' });
const t1 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
let r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t1.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); ok(r.json.ok, JSON.stringify(r.json)); const a1 = r.json.agentId;
// 같은 명령을 다시 실행(쓴 토큰)
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t1.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.status, 409); eq(r.json.error, 'token_used'); ok(r.json.message.includes('이미 등록된 서버'), r.json.message); ok(r.json.message.includes(a1));
// 새 토큰(재등록 허용 없음)
const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id }); ok(t2.existingAgent && t2.existingAgent.id === a1, '발급 응답이 기존 Agent 를 알린다');
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.status, 409); eq(r.json.error, 'server_already_registered'); ok(r.json.message.includes('이미 등록된 서버입니다') && r.json.message.includes('WS1') && r.json.message.includes(a1), r.json.message);
eq(core.getServer(h.d, srv.id).agent_id, a1, '서버의 Agent 는 그대로'); eq(core.getAgent(h.d, a1).status, 'active'); ok(!h.d.store.get('SELECT used_at FROM registration_tokens WHERE id=?', t2.id).used_at, '거부된 토큰은 소비되지 않는다');
// 서버 없는 managed 토큰 + 같은 호스트 이름 → 같은 거부
const t3 = core.issueRegistrationToken(h.d, { kind: 'managed' }); r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t3.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.json.error, 'server_already_registered');
// 재등록 허용 토큰
const t4 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id, replace: true }); eq(t4.replace, true);
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t4.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); ok(r.json.ok, JSON.stringify(r.json)); const a2 = r.json.agentId; ok(a2 !== a1);
eq(core.getServer(h.d, srv.id).agent_id, a2); eq(core.getAgent(h.d, a1).status, 'revoked'); eq(core.getAgent(h.d, a2).status, 'active'); ok(!h.d.vault.has(`service:agent:${a1}`), '옛 Agent 비밀 제거'); ok(h.d.vault.has(`service:agent:${a2}`));
const hb = await agentCall(h.app, a1, 'x', 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: {}, modules: {} }); eq(hb.status, 401, '해지된 Agent 는 거부');
ok(h.d.store.get("SELECT COUNT(*) AS n FROM audit_log WHERE action='agent.replace'").n >= 1, '감사 agent.replace');
// 운영자 API: replace 는 재인증 필요, 보통 발급은 재인증 없이
const op = await loginOp(h); h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, { replace: true }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, {}); eq(r.status, 200); eq(r.json.existingAgent.id, a2); eq(r.json.replace, false);
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date().toISOString()); r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, { replace: true }); eq(r.status, 200); eq(r.json.replace, true);
});
await test('[요구 1] Agent CLI register: CA 없이 자체 서명 HAL → 한 줄 원인·조치(스택 없음), --ca → HAL 응답, 등록 뒤 재실행 → 이미 등록된 서버, caFile 저장·런타임 CA 로 status 연결', async () => {
const h = makeHal(); const fx = await tlsFixture(h); h.d.config.publicUrl = fx.url;
try {
const data = join(tmpDir(), 'agent-data'); mkdirSync(data, { recursive: true });
let r = await agentCli(data, ['register', '--hal', fx.url, '--token', 'nope']); eq(r.status, 1); ok(r.stderr.includes('인증서를 확인할 수 없습니다') && r.stderr.includes('DEPTH_ZERO_SELF_SIGNED_CERT') && r.stderr.includes('--hal-pin') && r.stderr.includes('--ca'), r.stderr); ok(!r.stderr.includes('TypeError') && !r.stderr.includes('at async'), '스택 없음: ' + r.stderr);
r = await agentCli(data, ['register', '--hal', fx.url, '--token', 'nope', '--ca', fx.cert]); eq(r.status, 1); ok(r.stderr.includes('등록 실패: bad_token'), r.stderr);
const srv = core.createServer(h.d, { name: 'ws-cli', role: 'web' }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
r = await agentCli(data, ['register', '--hal', fx.url, '--token', t.token, '--ca', fx.cert, '--role', 'web', '--label', 'ws-cli']); eq(r.status, 0, r.stderr); ok(r.stdout.includes('등록 완료'));
const saved = JSON.parse(readFileSync(join(data, 'agent.json'), 'utf8')); eq(saved.caFile, fx.cert); eq(saved.serverId, srv.id); eq(loadAgentConfig({ ARIWEB_AGENT_DATA: data }).caFile, fx.cert);
// 다시 실행(새 토큰이어도) → 이미 등록된 서버, 종료 2, HAL 호출 없음
const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id }); r = await agentCli(data, ['register', '--hal', fx.url, '--token', t2.token, '--ca', fx.cert]); eq(r.status, 2); ok(r.stderr.includes('이미 등록된 서버입니다') && r.stderr.includes(saved.id), r.stderr); ok(!h.d.store.get('SELECT used_at FROM registration_tokens WHERE id=?', t2.id).used_at);
// status: agent.json 의 caFile 을 런타임에 적용해 자체 서명 HAL 에 연결(NODE_EXTRA_CA_CERTS 없이)
r = await agentCli(data, ['status']); eq(r.status, 0, r.stderr + r.stdout); ok(r.stdout.includes('연결 정상') && r.stdout.includes('runtime'), r.stdout);
// HAL 쪽 거부(이미 등록된 서버)도 읽을 수 있는 한 줄 + 종료 3
const data2 = join(tmpDir(), 'agent-data2'); mkdirSync(data2, { recursive: true }); r = await agentCli(data2, ['register', '--hal', fx.url, '--token', t2.token, '--ca', fx.cert]); eq(r.status, 3); ok(r.stderr.includes('server_already_registered') && r.stderr.includes('이미 등록된 서버'), r.stderr);
} finally { await fx.close(); }
// halca 단위: 메시지 분류·PEM 분할·적용
eq(friendlyFetchError({ cause: { code: 'SELF_SIGNED_CERT_IN_CHAIN' } }, 'https://hal.local.test/').tls, true); ok(friendlyFetchError({ cause: { code: 'ECONNREFUSED' } }, 'https://x').message.includes('연결할 수 없습니다'));
eq(splitPem('junk\n-----BEGIN CERTIFICATE-----\nA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nB\n-----END CERTIFICATE-----\n').length, 2); eq(applyHalCa(null).reason, 'no_ca'); eq(applyHalCa('/nonexistent/ca.pem').reason, 'missing'); eq(applyHalCa('/etc/hostname', { env: { NODE_EXTRA_CA_CERTS: '/etc/hostname' } }).via, 'env');
});
await test('[요구 2] 부트스트랩: HAL 서버 자신의 Agent(control·프로필 hal) 등록·승인으로 완료, 배너는 기능 단위 서버 배포 대기, observability 보고는 hawkeye 로', async () => {
const h = makeHal(); core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.4.1' }); core.setBootstrap(h.d, { release_version: '0.4.1', main_commit: 'abc', artifact_sha256: 'x' });
const op = await loginOp(h);
let page = await op.as('GET', '/ops/'); ok(page.text.includes('부트스트랩 진행 중') && page.text.includes('hal-host-agent.sh'), '완료 전: 진행 중 안내'); ok(!page.text.includes('기능 단위 서버 배포 대기'));
eq(core.selfHealth(h.d).bootstrap.complete, false); eq(core.selfHealth(h.d).deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']);
// HAL 서버 자신(프로필 hal: agent + hawkeye 만)
ok(SERVER_PROFILES.hal && SERVER_PROFILES.hal.role === 'control'); eq(SERVER_PROFILES.hal.modules, ['agent', 'hawkeye']); ok(MODULE_GUIDES.agent.install.includes('bootstrap-hal.sh'));
const srv = core.createServer(h.d, { name: 'hal21.ariweb.net', role: 'control', by: 'cli:bootstrap' }); core.updateServer(h.d, srv.id, { profile: 'hal', role: 'control', slots_json: '{}' }, 'cli:bootstrap');
const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id, by: 'cli:bootstrap' });
let r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'bear', units: { hal: 'ariweb-hal' } }, version: '0.4.1' } }); ok(r.json.ok); const ag = r.json;
r = await agentCall(h.app, ag.agentId, ag.secret, 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: { cpu: 1, mem: { total: 10, used: 5 }, disk: { total: 100, used: 10 }, load1: 0.1 }, modules: { agent: { version: '0.4.1', active: true, health: 'ok' }, observability: { version: '0.4.1', active: true, health: 'ok' }, hal: { version: '0.4.1', active: true, health: 'ok' } }, clockSkewMs: 0 }); ok(r.json.ok);
const mods = h.d.store.all('SELECT module FROM module_targets WHERE server_id=? ORDER BY module', srv.id).map((x) => x.module); eq(mods, ['agent', 'hal', 'hawkeye'], 'observability → hawkeye(옛 행 없음)');
const s = core.approveServer(h.d, srv.id, 'cli:bootstrap'); eq(s.status, 'ready'); eq(s.profile, 'hal');
const hh = core.selfHealth(h.d); eq(hh.bootstrap.complete, true); eq(hh.bootstrap.manualInstallLocked, true); eq(hh.bootstrap.firstAgent, ag.agentId); eq(hh.deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']); eq(hh.deployment.agents, 1);
page = await op.as('GET', '/ops/'); ok(!page.text.includes('부트스트랩 미완료') && !page.text.includes('부트스트랩 진행 중'), '완료 뒤 미완료 문구 없음'); ok(page.text.includes('부트스트랩 완료') && page.text.includes('기능 단위 서버 배포 대기') && page.text.includes('고객 포털, Webmaker, AriMail, 백업'), page.text.slice(page.text.indexOf('alerts'), page.text.indexOf('alerts') + 600));
r = await op.as('GET', '/ops/api/releases'); eq(r.json.deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']); const html = R.releasesPage ? R.releasesPage(r.json) : ''; if (html) ok(html.includes('기능 단위 서버 배포 대기') && !html.includes('부트스트랩 미완료'));
r = await invoke(h.app, 'GET', '/api/health'); eq(r.json.bootstrap.complete, true); eq(r.json.deployment.pending.length, 4);
// 기능 모듈이 어떤 서버에든 배치되면 대기 목록에서 빠진다(Webmaker 서버 추가 + heartbeat)
const web = core.createServer(h.d, { name: 'web1', role: 'web' }); const tw = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: web.id }); r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: tw.token, facts: { hostname: 'web1' }, version: '0.4.1' } }); const aw = r.json;
await agentCall(h.app, aw.agentId, aw.secret, 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: {}, modules: { agent: { version: '0.4.1', active: true, health: 'ok' }, webmaker: { version: '1.9.0', active: true, health: 'ok' } }, clockSkewMs: 0 });
eq(core.selfHealth(h.d).deployment.pending, ['portal', 'arimail', 'backup']); eq(core.deploymentStatus(h.d).deployed.map((i) => i.module), ['webmaker']);
page = await op.as('GET', '/ops/'); ok(page.text.includes('고객 포털, AriMail, 백업'));
eq(R.moduleLabel('observability'), 'HawkEye'); eq(R.moduleLabel('zzz'), 'zzz');
});
await test('[요구 2] CLI: server-create --profile hal · server-token --json · server-approve → 부트스트랩 완료(별도 프로세스)', async () => {
const dataDir = join(tmpDir(), 'hal-cli'); mkdirSync(dataDir, { recursive: true });
// HAL 기동이 남기는 실행 버전(claimLive) — 서비스 대신 같은 DB 로 기록
{ const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'http://127.0.0.1:1', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1, emergencyTtlMinutes: 30, values: {}, set() {} }; const d = createHalDeps({ config }); core.claimLive(d, { instanceId: d.instanceId, version: '0.4.1' }); d.store.close(); }
let r = halCli(dataDir, ['server-create', '--name', 'hal.local.test', '--role', 'control', '--profile', 'hal', '--by', 'bootstrap']); eq(r.status, 0, r.stderr); const srv = JSON.parse(r.stdout.trim()); ok(srv.id.startsWith('srv_')); eq(srv.profile, 'hal'); eq(srv.role, 'control');
r = halCli(dataDir, ['server-create', '--name', 'x', '--profile', 'nope']); eq(r.status, 1); ok(r.stderr.includes('알 수 없는 프로필'));
r = halCli(dataDir, ['server-token', '--server', srv.id, '--by', 'bootstrap', '--json']); eq(r.status, 0, r.stderr); const tok = JSON.parse(r.stdout.trim()); ok(tok.token && tok.installCommand.includes('--hal http://127.0.0.1:1')); eq(tok.tls.trusted, true, 'http 주소는 신뢰(핀 없음)'); ok(!tok.installCommand.includes('--hal-pin'));
r = halCli(dataDir, ['server-approve', '--server', srv.id, '--by', 'bootstrap']); eq(r.status, 1); ok(r.stderr.includes('agent_missing'), 'Agent 연결 전 승인 거부: ' + r.stderr);
// 등록(앱 없이 core 로 같은 DB) → 승인 CLI → 부트스트랩 완료
{ const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'http://127.0.0.1:1', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1, emergencyTtlMinutes: 30, values: {}, set() {} }; const d = createHalDeps({ config }); const reg = core.registerAgent(d, { token: tok.token, facts: { hostname: 'hal.local.test' }, version: '0.4.1' }); ok(reg.agentId); d.store.close(); }
r = halCli(dataDir, ['server-approve', '--server', srv.id, '--by', 'bootstrap']); eq(r.status, 0, r.stderr); const ap = JSON.parse(r.stdout.trim()); eq(ap.status, 'ready'); eq(ap.bootstrapComplete, true); eq(ap.pendingModules, ['portal', 'webmaker', 'arimail', 'backup']);
r = halCli(dataDir, ['tls-probe', '--url', 'http://127.0.0.1:1']); eq(r.status, 0, r.stderr); eq(JSON.parse(r.stdout).trusted, true);
// --replace 가 없으면 기존 Agent 가 있는 서버의 토큰 발급 출력에 주의가 붙는다
r = halCli(dataDir, ['server-token', '--server', srv.id]); eq(r.status, 0); ok(r.stdout.includes('이미 Agent') && r.stdout.includes('--replace'), r.stdout);
});
await test('[요구 1·2] 스크립트·문서·버전: agent-install.sh(핀·CA·이미 등록), hal-host-agent.sh, bootstrap-hal.sh 연결, 0.4.1 문서', async () => {
const inst = readFileSync(join(ROOT, 'deploy', 'agent-install.sh'), 'utf8');
for (const s of ['--hal-pin', '--ca', '--insecure', '--pinnedpubkey', '/agent/v1/hal-ca.pem', '--cacert', 'NODE_EXTRA_CA_CERTS', '이미 등록된 서버', '인증서 문제', 'Environment=NODE_EXTRA_CA_CERTS']) ok(inst.includes(s), `agent-install.sh: ${s}`);
const host = readFileSync(join(ROOT, 'deploy', 'hal-host-agent.sh'), 'utf8'); for (const s of ['server-create', '--profile hal', 'server-token', '--json', 'agent-install.sh', '--role control', 'server-approve', 'http://127.0.0.1:8900']) ok(host.includes(s), `hal-host-agent.sh: ${s}`);
const boot = readFileSync(join(ROOT, 'deploy', 'bootstrap-hal.sh'), 'utf8'); ok(boot.includes('hal-host-agent.sh') && boot.includes('--no-agent') && boot.includes('tls-probe') && boot.includes('6/7') && boot.includes('7/7'));
for (const f of ['agent-install.sh', 'hal-host-agent.sh', 'bootstrap-hal.sh']) { const r = spawnSync('bash', ['-n', join(ROOT, 'deploy', f)], { encoding: 'utf8' }); eq(r.status, 0, `${f} bash -n: ${r.stderr}`); }
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(makeHal().d.version, '0.4.1');
ok(existsSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.1.md')) && readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.1.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.4.1'));
ok(readFileSync(join(ROOT, 'README.md'), 'utf8').includes('0.4.1')); ok(readFileSync(join(ROOT, 'docs', 'HAL-AGENT.md'), 'utf8').includes('hal-ca.pem'));
// 도입 모듈은 바뀌지 않았다(HAL 0.4.0 대응 그대로 — 0.4.1 호환)
const wm = JSON.parse(readFileSync(join(ROOT, 'modules', 'webmaker', 'package.json'), 'utf8')); eq(wm.version, '1.9.0'); eq(wm.ariwebSuite.halCompat, '0.4.0');
const am = JSON.parse(readFileSync(join(ROOT, 'modules', 'arimail', 'package.json'), 'utf8')); eq(am.version, '1.1.0'); eq(am.ariwebSuite.halCompat, '0.4.0');
ok(!readFileSync(join(ROOT, 'agent', 'index.mjs'), 'utf8').includes('out.observability'), 'Agent 는 hawkeye 로 보고');
});
done();
+1 -1
View File
@@ -58,7 +58,7 @@ await test('버전·문서(0.3.10)', async () => {
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('no net'); } }); const app = buildApp(d);
const pw = 'pw-' + randomBytes(12).toString('hex'); ops.createAccount(d, { login: 'ozops', password: pw, displayName: '오즈' }); const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } });
const r = await invoke(app, 'GET', '/ops/ai', { cookies: { aw_ops: l.cookies.aw_ops } }); ok(r.text.includes('복사'), '셸 안내에 복사 방법');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(d.version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.10.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.10'));
});
done();
+1 -1
View File
@@ -192,7 +192,7 @@ await test('화면·버전·문서(0.3.2): AI 연동 화면에 프리셋·사전
for (const s of ['OpenRouter', 'Gemini', 'ChatGPT', '기타', '사전 준비', '텍스트 셸', 'MCP', '도구 정책']) ok(r.text.includes(s), '화면 문구 ' + s);
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.shellHtml === 'function' && typeof AR.guideHtml === 'function' && typeof AR.toolPolicyHtml === 'function');
ok(AR.shellHtml({ id: 's1', status: 'running', tool: 'claude', command: 'login' }, 'Open <https://x> paste').includes('&lt;https://x&gt;'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.2.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.2'));
});
done();
+1 -1
View File
@@ -162,7 +162,7 @@ await test('화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.modelSelectHtml === 'function' && typeof AR.shellSessionsHtml === 'function' && typeof AR.commandsHelpHtml === 'function');
ok(AR.modelSelectHtml({ id: 'c1', model: 'a', models: ['a', 'b<'] }).includes('b&lt;') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.9.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.9'));
});
done();
+2 -2
View File
@@ -302,7 +302,7 @@ await test('화면·버전·문서: 공통 틀에 AI 패널(통합 탭 고정)·
r = await invoke(h.app, 'GET', '/ops/ui/ai.js'); eq(r.status, 200); r = await invoke(h.app, 'GET', '/ops/ui/ai-render.mjs'); eq(r.status, 200);
const AR = await import('../hal/ui/public/ai-render.mjs'); const tabs = AR.tabsHtml([{ id: 'room_group', kind: 'group', title: '통합', fixed: true }, { id: 'r2', kind: 'direct', title: '<네오>', connection: { status: 'active' } }], 'r2'); ok(tabs.indexOf('통합') < tabs.indexOf('&lt;네오&gt;') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('&lt;b&gt;'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
r = await op.as('GET', '/ops/api/ai/skill'); eq(r.status, 200); ok(r.json.skill.includes('pending') && r.json.skill.includes('needs_user') && r.json.skill.includes('reauth_required'), '상태·로그 의미 설명');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.0.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.0'); ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.1.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.1'); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.1'));
ok(readFileSync(join(ROOT, 'docs', 'HAL-AI.md'), 'utf8').includes('hal_servers_list'), 'AI 도구 문서');
@@ -315,7 +315,7 @@ await test('MCP(stdio) 서버: initialize·tools/list·읽기 도구 call 은
const send = (o) => child.stdin.write(JSON.stringify(o) + '\n'); const waitFor = async (id) => { for (let i = 0; i < 200; i++) { const m = out.find((x) => x.id === id); if (m) return m; await sleep(25); } throw new Error('MCP 응답 없음 ' + id); };
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test' } } }); const init = await waitFor(1); ok(init.result.serverInfo.name.includes('HAL') && init.result.capabilities.tools);
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_servers_list' && t.inputSchema)); ok(!tl.result.tools.some((t) => /shell|exec/.test(t.name)));
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.4.0');
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.4.1');
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'k', title: 't', severity: 'warning' } } }); const wc = await waitFor(4); ok(!wc.result.isError && /승인/.test(wc.result.content[0].text) && JSON.parse(wc.result.content[0].text).approvalRequested === true, JSON.stringify(wc)); // [0.3.2] 오류가 아니라 승인 요청(제안)으로 올라간다
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'nope', arguments: {} } }); const nc = await waitFor(5); ok(nc.error || nc.result.isError);
child.kill(); await sleep(50);
+1 -1
View File
@@ -58,7 +58,7 @@ await test('일반 화면에는 고위험 재인증 전역 배너가 없다 —
ok(r.text.includes('data-act="reauth"'), '계정 화면의 수동 재인증 단추 유지');
ok(!r.text.includes(BANNER) && !r.text.includes('고위험 작업'), '계정 화면에 고위험 안내 없음');
// 다른 배너(부트스트랩 미완료)는 그대로 — 배너 영역 자체를 없앤 것이 아님
ok(alerts(r.text).includes('부트스트랩 미완료'), '다른 전역 배너는 유지');
ok(alerts(r.text).includes('부트스트랩 진행 중'), '다른 전역 배너는 유지');
});
await test('backend 보안 경계는 그대로: 10분 경과 뒤 고위험 API 는 403 reauth_required, CSRF 없는 재인증 거부, 재인증 뒤에만 통과', async () => {
+5 -5
View File
@@ -35,7 +35,7 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠
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"'));
ok(r.text.includes('부트스트랩 진행 중'), '부트스트랩 상태 배너'); ok(r.text.includes('data-page="overview"'));
// 로그인 화면은 고객 로그인과 분리된 문구
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
});
@@ -44,23 +44,23 @@ await test('부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약(c
// 계약 고정: 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 실행 버전 확인 전, 첫 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, 첫 Agent 미등록)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
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/'); 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 실행 버전: <b>0.1.1</b>') && 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 실행 버전 확인 전, 첫 Agent 등록됨)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
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);
+2 -2
View File
@@ -19,10 +19,10 @@ const require = createRequire(import.meta.url);
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WM = join(ROOT, 'modules', 'webmaker');
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.9.0(HAL 0.4.0 대응, 직접 편집 유지) · Suite 0.4.0(통합 설정 관제) 이다', () => {
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.9.0(HAL 0.4.0 대응, 직접 편집 유지) · Suite 0.4.1(통합 설정 관제 + 등록 TLS·부트스트랩 Agent) 이다', () => {
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.9.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1');
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');