2026-10-28 16:50:12 +00:00
/**
* test/hal-reauth-banner.test.mjs — Ariweb Suite 0.2.1(개발 지시 `Ariweb-Suitle_Dev-Order_v0.2.1.md`): 운영자 콘솔 고위험 재인증 안내 위치 회귀 테스트.
*
* 계약
* 1) 일반 화면(운영 현황·서버·계정 …)에는 "고위험 작업(…)은 최근 10분 안의 재인증이 필요합니다" 전역 배너가 **없다** — 로그인 직후에도, 10분이 지난 뒤에도, 화면을 옮겨 다녀도.
* 2) 그 안내는 backend 가 고위험 요청을 `reauth_required` 로 거부한 바로 그 때 여는 재인증 창(modal) 안에만 보인다. 계정 화면의 수동 "재인증" 단추는 그대로 두되 고위험 안내를 붙이지 않는다.
* 3) backend 의 requireRecent·CSRF·3단계 확인은 그대로다: 10분이 지나면 고위험 API 는 403 reauth_required, 재인증 뒤에만 통과.
* 4) 재인증 창을 취소하면 원래 고위험 요청은 실행되지 않고, 재인증에 성공하면 원 요청을 **딱 한 번** 재시도한다(두 번째도 거부되면 다시 묻지 않고 오류를 그대로 올린다).
*
* 브라우저 스크립트(hal/ui/public/ops.js)는 DOM 이 있어야 돌므로, 재인증 흐름·창 문구는 순수 helper(hal/ui/public/reauth.mjs)로 분리해 Node 에서 검사하고,
* ops.js 가 실제로 그 helper 를 쓰는지는 정적 계약(소스 텍스트)으로 못 박는다.
*/
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 { mkdirSync , readFileSync } from 'node:fs' ;
import { randomBytes } from 'node:crypto' ;
import { join , dirname } from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
const ROOT = join ( dirname ( fileURLToPath ( import . meta . url )), '..' );
/** 실행마다 다른 운영자 비밀번호 fixture(저장소에 정적 비밀번호 literal 을 두지 않는다). correct 와 wrong 은 서로 다름이 보장된다 */
const makeFixturePasswords = () => { const correct = 'fx-' + randomBytes ( 12 ). toString ( 'hex' ); let wrong ; do { wrong = 'fx-' + randomBytes ( 12 ). toString ( 'hex' ); } while ( wrong === correct ); return { correct , wrong }; };
const BANNER = '고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다' ;
function makeHal () {
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 ); } };
const d = createHalDeps ({ config , dbFile : ':memory:' , fetchImpl : async () => { throw new Error ( 'ECONNREFUSED' ); } });
return { d , app : buildApp ( d ) };
}
async function loginOp ( h ) {
const pw = makeFixturePasswords ();
ops . createAccount ( h . d , { login : 'ozops' , password : pw . correct , displayName : '오즈' });
const r = await invoke ( h . app , 'POST' , '/ops/api/login' , { body : { login : 'ozops' , password : pw . correct } }); ok ( r . json . ok , JSON . stringify ( r . json ));
return { pw , as : ( method , path , body ) => invoke ( h . app , method , path , { body , cookies : { aw_ops : r . cookies . aw_ops }, headers : { 'x-csrf' : r . json . csrf } }) };
}
/** 화면 HTML 의 전역 배너 영역(<div class="alerts">…</div>)만 떼어 낸다 */
const alerts = ( html ) => ( html . match ( /<div class="alerts">([\s\S]*?)<\/div><div id="body">/ ) || [ '' , '' ])[ 1 ];
await test ( '일반 화면에는 고위험 재인증 전역 배너가 없다 — 로그인 직후·10분 경과·화면 이동 모두' , async () => {
const h = makeHal (); const op = await loginOp ( h );
// 로그인 직후(재인증 유효): 배너 없음
let r = await op . as ( 'GET' , '/ops/' ); eq ( r . status , 200 ); ok ( ! r . text . includes ( BANNER ), '로그인 직후 운영 현황에 고위험 배너 없음' ); ok ( ! alerts ( r . text ). includes ( 'data-act="reauth"' ), '배너 영역에 재인증 링크 없음' );
// 10분 경과(재인증 만료): 그래도 일반 화면에는 배너 없음
h . d . store . run ( 'UPDATE sessions SET reauth_at=?' , new Date ( Date . now () - 11 * 60000 ). toISOString ());
for ( const path of [ '/ops/' , '/ops/servers' , '/ops/releases' , '/ops/jobs' , '/ops/recovery' , '/ops/customers' ]) {
r = await op . as ( 'GET' , path ); eq ( r . status , 200 , path );
ok ( ! r . text . includes ( BANNER ), ` ${ path } : 10분 경과 뒤에도 고위험 배너 없음` );
ok ( ! alerts ( r . text ). includes ( 'data-act="reauth"' ), ` ${ path } : 배너 영역에 재인증 링크 없음` );
ok ( ! r . text . includes ( '고위험 작업(' ), ` ${ path } : 단순 조회 화면에 고위험 안내 문구 없음` );
}
// 계정 화면: 수동 재인증 단추는 유지, 고위험 안내는 붙이지 않는다
r = await op . as ( 'GET' , '/ops/account' ); eq ( r . status , 200 );
ok ( r . text . includes ( 'data-act="reauth"' ), '계정 화면의 수동 재인증 단추 유지' );
ok ( ! r . text . includes ( BANNER ) && ! r . text . includes ( '고위험 작업' ), '계정 화면에 고위험 안내 없음' );
// 다른 배너(부트스트랩 미완료)는 그대로 — 배너 영역 자체를 없앤 것이 아님
2026-11-06 08:40:09 +00:00
ok ( alerts ( r . text ). includes ( '부트스트랩 진행 중' ), '다른 전역 배너는 유지' );
2026-10-28 16:50:12 +00:00
});
await test ( 'backend 보안 경계는 그대로: 10분 경과 뒤 고위험 API 는 403 reauth_required, CSRF 없는 재인증 거부, 재인증 뒤에만 통과' , async () => {
const h = makeHal (); const op = await loginOp ( h );
const s = core . createServer ( h . d , { name : '웹1' , role : 'web' });
h . d . store . run ( 'UPDATE sessions SET reauth_at=?' , new Date ( Date . now () - 11 * 60000 ). toISOString ());
let r = await op . as ( 'POST' , `/ops/api/servers/ ${ s . id } /delete` , { confirmImpact : true , confirmIdentifiers : [ s . id ], confirmExecute : true }); eq ( r . status , 403 ); eq ( r . json . error , 'reauth_required' ); eq ( r . json . detail && r . json . detail . action , 'reauth' );
ok ( core . getServer ( h . d , s . id ), '거부된 고위험 요청은 실행되지 않음(서버 그대로)' );
r = await op . as ( 'PATCH' , `/ops/api/servers/ ${ s . id } ` , { status : 'isolated' }); eq ( r . json . error , 'reauth_required' );
// 재인증 자체도 CSRF 가 필요하다
r = await invoke ( h . app , 'POST' , '/ops/api/reauth' , { body : { password : op . pw . correct }, cookies : { aw_ops : ( await invoke ( h . app , 'POST' , '/ops/api/login' , { body : { login : 'ozops' , password : op . pw . correct } })). cookies . aw_ops } }); eq ( r . status , 403 ); eq ( r . json . error , 'csrf' );
r = await op . as ( 'POST' , '/ops/api/reauth' , { password : op . pw . wrong }); ok ( r . status >= 400 , '틀린 비밀번호 재인증 거부' );
r = await op . as ( 'POST' , '/ops/api/reauth' , { password : op . pw . correct }); eq ( r . status , 200 );
r = await op . as ( 'GET' , '/ops/api/me' ); ok ( r . json . recentlyAuthed , '재인증 뒤 recentlyAuthed' );
// 3단계 확인은 재인증과 별개로 여전히 요구된다
r = await op . as ( 'POST' , `/ops/api/servers/ ${ s . id } /delete` , {}); ok ( r . status >= 400 && r . json . error !== 'reauth_required' , `3단계 확인 없이는 거부( ${ r . json . error } )` ); ok ( core . getServer ( h . d , s . id ), '서버 그대로' );
});
await test ( '재인증 helper(순수): 고위험 안내는 reauth_required 로 거부된 때의 재인증 창에만, 수동 재인증 창에는 없음' , async () => {
const H = await import ( '../hal/ui/public/reauth.mjs' );
eq ( H . REAUTH_REQUIRED , 'reauth_required' );
eq ( H . HIGH_RISK_NOTICE , BANNER , '안내 문구는 옛 전역 배너 문구와 같다(위치만 옮김)' );
const manual = H . reauthPrompt (); ok ( manual && manual . title && manual . okLabel , '수동 재인증 창 제목·단추' ); eq ( manual . note , '' , '수동 재인증 창에는 고위험 안내 없음' );
const forced = H . reauthPrompt ({ required : true }); eq ( forced . note , BANNER , '거부된 때의 재인증 창에는 고위험 안내' ); eq ( forced . title , manual . title );
});
await test ( '재인증 helper(순수): 취소하면 원 요청 미실행·오류 전파, 성공하면 딱 한 번 재시도, 다른 오류는 재인증을 묻지 않음' , async () => {
const H = await import ( '../hal/ui/public/reauth.mjs' );
const denied = () => Object . assign ( new Error ( '고위험 작업은 최근 10분 안의 재인증이 필요합니다' ), { code : 'reauth_required' , status : 403 });
// 1) 취소 → 원 요청은 1번(거부된 호출)만, 재인증 창은 required 로 1번, 오류는 그대로 전파
let runs = 0 ; let prompts = [];
let err = null ; try { await H . callWithReauth (() => { runs ++ ; throw denied (); }, async ( o ) => { prompts . push ( o ); return false ; }); } catch ( e ) { err = e ; }
eq ( runs , 1 , '취소하면 원 요청을 다시 보내지 않음' ); eq ( prompts . length , 1 ); eq ( prompts [ 0 ]. required , true ); eq ( err && err . code , 'reauth_required' , '원래 오류가 호출자에게 전파' );
// 2) 성공 → 원 요청 2번(거부 + 재시도 1번), 결과는 재시도 값
runs = 0 ; prompts = [];
const out = await H . callWithReauth (() => { runs ++ ; if ( runs === 1 ) throw denied (); return { ok : true , n : runs }; }, async ( o ) => { prompts . push ( o ); return true ; });
eq ( runs , 2 , '재인증 성공 시 딱 한 번 재시도' ); eq ( out . n , 2 ); eq ( prompts . length , 1 );
// 3) 재시도도 거부 → 다시 묻지 않고 오류 전파(무한 반복 금지)
runs = 0 ; prompts = [];
err = null ; try { await H . callWithReauth (() => { runs ++ ; throw denied (); }, async ( o ) => { prompts . push ( o ); return true ; }); } catch ( e ) { err = e ; }
eq ( runs , 2 ); eq ( prompts . length , 1 , '재시도가 또 거부돼도 재인증 창을 다시 열지 않음' ); eq ( err && err . code , 'reauth_required' );
// 4) 다른 오류(권한 없음·검증 실패)는 재인증을 묻지 않는다
runs = 0 ; prompts = [];
err = null ; try { await H . callWithReauth (() => { runs ++ ; throw Object . assign ( new Error ( 'x' ), { code : 'bad_request' }); }, async ( o ) => { prompts . push ( o ); return true ; }); } catch ( e ) { err = e ; }
eq ( runs , 1 ); eq ( prompts . length , 0 ); eq ( err && err . code , 'bad_request' );
// 5) 정상 응답은 그대로
eq (( await H . callWithReauth ( async () => 7 , async () => { throw new Error ( '부르면 안 됨' ); })), 7 );
});
await test ( '정적 계약: ui.mjs 에 전역 고위험 배너가 없고, ops.js 는 reauth helper 로 거부 시점 재인증·수동 재인증(안내 없음)을 처리한다' , () => {
const ui = readFileSync ( join ( ROOT , 'hal' , 'ui.mjs' ), 'utf8' ); const js = readFileSync ( join ( ROOT , 'hal' , 'ui' , 'public' , 'ops.js' ), 'utf8' );
ok ( ! ui . includes ( '고위험 작업(' ), 'hal/ui.mjs 는 고위험 배너 문구를 더 이상 그리지 않음' );
ok ( ! /if \(!recent\) banner\.push/ . test ( ui ), '재인증 만료를 전역 배너로 바꾸는 코드가 없음' );
ok ( js . includes ( "from './reauth.mjs'" ), 'ops.js 가 순수 helper 를 가져온다' );
ok ( js . includes ( 'callWithReauth(' ), 'API 호출 재시도 흐름은 helper 가 담당' );
ok ( js . includes ( 'reauthPrompt(' ), '재인증 창 문구는 helper 가 결정' );
ok ( /\breauth: \(\) => reauth\(\)/ . test ( js ), '계정 화면 수동 재인증은 required 없이 호출' );
ok ( ! js . includes ( '고위험 작업(' ), 'ops.js 는 안내 문구를 직접 품지 않는다(helper 단일 출처)' );
});
done ();