awaittest('부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약(complete·halRunningVersion·firstAgent)만 본다 — 완료 뒤에는 경고가 사라지고, 미완료 때는 실행 버전·첫 Agent 상태가 정확하다',async()=>{
consth=makeHal();constop=awaitloginOp(h);
// 계약 고정: selfHealth 는 camelCase 만 내고 옛 snake_case 열 이름은 없다(배너가 어떤 필드를 읽는지 못 박는다)
constb0=core.selfHealth(h.d).bootstrap;eq(b0.complete,false);ok(!('completed_at'inb0)&&!('hal_running_version'inb0)&&!('first_agent_id'inb0),'selfHealth.bootstrap 에 snake_case 필드가 없다');
// 1) 아무것도 안 된 상태: 실행 버전 확인 전 · 첫 Agent 미등록
letr=awaitop.as('GET','/ops/');eq(r.status,200);ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)'),'초기 상태 문구');
// 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록
r=awaitop.as('GET','/ops/');ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 0.1.1, 첫 Agent 미등록)'),`실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/)||[''])[0]}`);
// 3) 첫 Agent 등록·승인 → 부트스트랩 완료(core.approveServer → maybeCompleteBootstrap). 경고는 사라져야 한다
r=awaitop.as('GET','/ops/');eq(r.status,200);ok(!r.text.includes('부트스트랩 미완료'),`완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/)||[''])[0]}`);
r=awaitop.as('GET','/ops/servers');ok(!r.text.includes('부트스트랩 미완료'),'완료 뒤 다른 화면(서버)에도 경고 없음');
r=awaitop.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 등록됨
r=awaitop2.as('GET','/ops/');ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 등록됨)'),`첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/)||[''])[0]}`);
constreg=awaitinvoke(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=?',newDate().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);
r=awaitop.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=awaitop.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=awaitop.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로 실행하지 않음');
r=awaitop.as('GET','/ops/releases');ok(r.text.includes('HAL 은 배포 대상이 아닙니다')&&r.text.includes('abcdef123456')&&r.text.includes('테스트 통과')&&r.text.includes('승인 대기'),'[0.3.17] 릴리스 화면: HAL 제외 안내·과거 버전 표');
eq(deps.store.get('SELECT maintenance FROM sites WHERE id=?','s_1').maintenance,1);
constau=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=awaitcall(app,'POST',`/internal/ops/customers/${who.group.id}/change`,{field:'site.name',subjectId:'s_other',value:'x',reason:'다른 그룹 대상 시험'});eq(r.status,404);