Files
AriWeb-Suite/test/hal-cli.test.mjs
T
zzabisagent-labandClaude Fable 5.1 dfb708e933 HAL CLI(시스템 신원) 보완: config-set(운영 값 기록·비밀 거부·감사) / module-deploy(운영자 API 와 같은 멱등 규칙의 module.deploy 작업 큐 투입, 실행은 서비스 프로세스) / job(작업·자식 readback) + 테스트 3건
운영자 콘솔 자격 증명이 없는 연동 단계(포털·Node Agent 제어 평면을 hal.ariweb.net → hal21.ariweb.net 으로 옮기는 작업)에서 HAL 데이터 소유자가 감사 기록(actor cli:<by>, reason)을 남기며 쓰는 명령. 직접 SQL 없이 기존 core/jobs 경로만 사용한다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 09:27:23 +00:00

59 lines
6.9 KiB
JavaScript

/** test/hal-cli.test.mjs — HAL CLI(시스템 신원) 회귀 테스트: config-set / module-deploy / job.
* 운영자 콘솔이 닿지 않는 연동 단계에서 HAL 데이터 소유자가 쓰는 명령이다. 별도 프로세스(node hal/index.mjs …)로 실행해 실제 CLI 경로를 검증하고,
* 같은 데이터 디렉터리를 연 서비스 측 작업 엔진이 CLI 가 넣은 작업을 집어 실행하는지(실행은 serve 프로세스 몫) 확인한다. 비밀값은 출력하지 않는다. */
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import * as core from '../hal/core.mjs';
import { listAudit } from '../lib/audit.mjs';
import { execFileSync } from 'node:child_process';
import { readFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
function makeDataDir() { const dir = join(tmpDir('hal-cli-'), 'data'); mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; }
/** CLI 를 별도 프로세스로. 실패(종료 코드≠0)는 { code, stdout, stderr } 로 돌려준다. */
function cli(dataDir, args) {
try { const out = execFileSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }); return { code: 0, stdout: out, stderr: '' }; }
catch (e) { return { code: e.status, stdout: String(e.stdout || ''), stderr: String(e.stderr || '') }; }
}
const deps = (dataDir) => createHalDeps({ 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: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1000, emergencyTtlMinutes: 30, agentPollMs: 10, values: {}, set() {} } });
await test('config-set: 운영 값만 data/config.json 에 기록(actor cli:<by>, 감사), 인자 없으면 거부, 비밀 키는 받지 않음', () => {
const dir = makeDataDir();
eq(cli(dir, ['config-set']).code, 1, '바꿀 값이 없으면 실패');
const r = cli(dir, ['config-set', '--portalUrl', 'https://my.example.test', '--publicUrl', 'https://hal21.example.test', '--by', 'hera', '--reason', 'control-plane move']);
eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); eq(out.updated.sort(), ['portalUrl', 'publicUrl']);
const cfg = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')); eq(cfg.values.portalUrl, 'https://my.example.test'); eq(cfg.values.publicUrl, 'https://hal21.example.test'); eq(cfg.updatedBy, 'cli:hera');
const d = deps(dir); const a = listAudit(d.store, { action: 'config.update' }); eq(a.length, 1); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a[0].detail_json).keys.sort(), ['portalUrl', 'publicUrl']); eq(JSON.parse(a[0].detail_json).reason, 'control-plane move');
// 비밀값 이름은 config-set 이 받는 키 목록에 없다 — 무시되어 '바꿀 값 없음' 으로 거부된다(설정 파일에 비밀을 두지 않는다)
const s = cli(dir, ['config-set', '--apiKey', 'x']); eq(s.code, 1); ok(!readFileSync(join(dir, 'config.json'), 'utf8').includes('apiKey'));
});
await test('module-deploy: 운영자 API 와 같은 멱등 규칙으로 module.deploy 작업을 큐에 넣는다(실행은 서비스 프로세스), 검증 실패는 종료 코드 1, job 명령으로 readback', () => {
const dir = makeDataDir(); const d = deps(dir);
const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id]).code, 1, '릴리스가 없으면 거부');
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' });
eq(cli(dir, ['module-deploy', '--module', 'nope', '--version', '0.1.1', '--servers', srv.id]).code, 1, '알 수 없는 모듈 거부');
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', 'srv_none']).code, 1, '없는 서버 거부');
const r = cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--reason', 'portal → hal21 link']);
eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); ok(out.jobId.startsWith('j_')); eq(out.created, true); eq(out.status, 'queued');
const j = d.jobs.get(out.jobId); eq(j.kind, 'module.deploy'); eq(j.subject, { module: 'portal', version: '0.1.1', serverIds: [srv.id] }); eq(j.created_by, 'cli:hera'); eq(j.actor_type, 'operator'); eq(j.idempotency_key, `module.deploy:portal:0.1.1:${srv.id}:`);
const again = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop()); eq(again.jobId, out.jobId); eq(again.created, false, '같은 멱등 키 → 같은 작업');
const nonce = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--nonce', '2']).stdout.trim().split('\n').pop()); ok(nonce.jobId !== out.jobId && nonce.created, 'nonce 로 새 작업');
const a = listAudit(d.store, { action: 'deploy.module.request' }); eq(a.length, 3); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a.find((x) => JSON.parse(x.detail_json).jobId === out.jobId && JSON.parse(x.detail_json).created).detail_json).reason, 'portal → hal21 link');
const rb = cli(dir, ['job', '--id', out.jobId]); eq(rb.code, 0, rb.stderr); const view = JSON.parse(rb.stdout); eq(view.job.id, out.jobId); eq(view.job.status, 'queued'); eq(view.children, []);
eq(cli(dir, ['job', '--id', 'j_none']).code, 1);
});
await test('CLI 가 넣은 작업을 같은 데이터의 서비스 측 작업 엔진이 집어 실행한다(artifact 없음·부트스트랩 미완료 → precheck 실패로 끝남, 큐에 남지 않음)', async () => {
const dir = makeDataDir(); const d = deps(dir); const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' });
const out = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop());
d.jobsRunning = true; const n = await d.jobs.drain(); ok(n >= 1, '서비스 측 엔진이 큐 작업을 집었다');
const j = d.jobs.get(out.jobId); eq(j.status, 'failed'); ok(['artifact_missing', 'bootstrap_incomplete'].includes(j.error_code), `precheck 실패로 끝남: ${j.error_code}`); eq(d.jobs.list({ status: 'queued' }).length, 0);
});
done();