2026-10-26 12:15:48 +00:00
/** 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 }) }; }
2026-10-27 04:42:45 +00:00
await test ( 'HAL 루트 주소는 운영자 콘솔 기본 경로로 안내한다' , async () => {
const h = makeHal ();
const r = await invoke ( h . app , 'GET' , '/' );
eq ( r . status , 302 , '루트 주소는 404가 아니라 운영자 콘솔로 이동' );
eq ( r . headers . Location , '/ops/' , '운영자 콘솔의 표준 경로로 이동' );
});
2026-10-26 12:15:48 +00:00
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 );
2026-11-06 08:40:09 +00:00
ok ( r . text . includes ( '부트스트랩 진행 중' ), '부트스트랩 상태 배너' ); ok ( r . text . includes ( 'data-page="overview"' ));
2026-10-26 12:15:48 +00:00
// 로그인 화면은 고객 로그인과 분리된 문구
r = await invoke ( h . app , 'GET' , '/ops/login' ); ok ( r . text . includes ( '고객 계정으로는 로그인할 수 없습니다' ));
});
2026-10-27 14:31:59 +00:00
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 미등록
2026-11-06 08:40:09 +00:00
let r = await op . as ( 'GET' , '/ops/' ); eq ( r . status , 200 ); ok ( r . text . includes ( '부트스트랩 진행 중(HAL 실행 버전 확인 전, HAL 서버의 Node Agent 미설치)' ), '초기 상태 문구' );
2026-10-27 14:31:59 +00:00
// 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록
core . claimLive ( h . d , { instanceId : h . d . instanceId , version : '0.1.1' });
2026-11-06 08:40:09 +00:00
r = await op . as ( 'GET' , '/ops/' ); ok ( r . text . includes ( '부트스트랩 진행 중(HAL 실행 버전 0.1.1, HAL 서버의 Node Agent 미설치)' ), `실행 버전 0.1.1 표시: ${ ( r . text . match ( /부트스트랩 진행 중\([^)]*\)/ ) || [ '' ])[ 0 ] } ` );
2026-10-27 14:31:59 +00:00
// 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 );
2026-11-06 08:40:09 +00:00
r = await op . as ( 'GET' , '/ops/' ); eq ( r . status , 200 ); ok ( ! r . text . includes ( '부트스트랩 미완료' ), `완료 뒤 운영 현황에 경고 없음: ${ ( r . text . match ( /부트스트랩 진행 중\([^)]*\)/ ) || [ '' ])[ 0 ] } ` );
2026-10-27 14:31:59 +00:00
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 );
2026-11-06 08:40:09 +00:00
r = await op2 . as ( 'GET' , '/ops/' ); ok ( r . text . includes ( '부트스트랩 진행 중(HAL 실행 버전 확인 전, HAL 서버의 Node Agent 등록됨 — 승인 대기)' ), `첫 Agent 등록됨 표시: ${ ( r . text . match ( /부트스트랩 진행 중\([^)]*\)/ ) || [ '' ])[ 0 ] } ` );
2026-10-27 14:31:59 +00:00
});
2026-10-26 12:15:48 +00:00
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 });
2026-10-27 08:56:32 +00:00
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 );
2026-10-26 12:15:48 +00:00
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' ), '서버 표에 상태·모듈 차이' );
2026-10-27 08:56:32 +00:00
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 ( '기존 사이트 <A>' ) && r . text . includes ( '<idle>' ) && ! 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<script>' ) && ! 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 ( '<id>' ) && ! r . text . includes ( '<img onerror=1>' ), 'null 등 malformed agent facts도 렌더러를 깨거나 HTML로 실행하지 않음' );
HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
2026-10-29 03:32:46 +00:00
r = await op . as ( 'GET' , '/ops/releases' ); ok ( r . text . includes ( 'HAL 은 배포 대상이 아닙니다' ) && r . text . includes ( 'abcdef123456' ) && r . text . includes ( '테스트 통과' ) && r . text . includes ( '승인 대기' ), '[0.3.17] 릴리스 화면: HAL 제외 안내·과거 버전 표' );
2026-10-26 12:15:48 +00:00
// 작업: 부모 + 자식(하나 실패) → 부분 실패 표시
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 ();