2026-10-27 09:27:23 +00:00
/** 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 , '릴리스가 없으면 거부' );
2026-10-28 15:07:35 +00:00
core . registerRelease ( d , { version : '0.1.1' , mainCommit : 'abc' , builtAt : 'now' , artifacts : { portal : { file : 'portal-0.1.1.tar.gz' , sha256 : 'x' , size : 1 } }, checks : { testsPassed : true , dirty : false , provenanceClean : true , provenanceProblems : [] }, approve : true , by : 'test' }); // 승인은 세 검사가 모두 깨끗해야 한다(0.2.0)
2026-10-27 09:27:23 +00:00
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' });
2026-10-28 15:07:35 +00:00
core . registerRelease ( d , { version : '0.1.1' , mainCommit : 'abc' , builtAt : 'now' , artifacts : { portal : { file : 'portal-0.1.1.tar.gz' , sha256 : 'x' , size : 1 } }, checks : { testsPassed : true , dirty : false , provenanceClean : true , provenanceProblems : [] }, approve : true , by : 'test' }); // 승인은 세 검사가 모두 깨끗해야 한다(0.2.0)
2026-10-27 09:27:23 +00:00
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 ();