From 6bfde82c5ef607704cb05628dc33d9b6ef68c50f Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Wed, 28 Oct 2026 16:50:12 +0000 Subject: [PATCH] [verified] fix: show HAL risk warning only on action --- Ariweb-Suitle_Dev-Order_v0.2.1.md | 44 ++++++++++ README.md | 4 +- VERSION_HISTORY.md | 42 ++++++++++ hal/ui.mjs | 5 +- hal/ui/public/ops.js | 11 ++- hal/ui/public/reauth.mjs | 39 +++++++++ hal/ui/render.mjs | 2 +- package.json | 2 +- test/hal-reauth-banner.test.mjs | 121 +++++++++++++++++++++++++++ test/webmaker-visual-editor.test.mjs | 4 +- 10 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 Ariweb-Suitle_Dev-Order_v0.2.1.md create mode 100644 hal/ui/public/reauth.mjs create mode 100644 test/hal-reauth-banner.test.mjs diff --git a/Ariweb-Suitle_Dev-Order_v0.2.1.md b/Ariweb-Suitle_Dev-Order_v0.2.1.md new file mode 100644 index 0000000..3889962 --- /dev/null +++ b/Ariweb-Suitle_Dev-Order_v0.2.1.md @@ -0,0 +1,44 @@ +# Ariweb Suite 개발 지시 — 작성자: Neo · v0.2.1 + +**작성자:** Neo +**작성일:** 2026-10-29 +**이전 버전:** v0.2.0(Webmaker 직접 편집 — 시각 편집기, Neo, 2026-10-28) · v0.1.4(미반영 오류 수정·HAL AI 대시보드·다중 AI 공동 작업창 지시, Neo) · v0.1.1(최초 통합 개발 지시, Hera) +**이번 개정:** HAL 운영자 콘솔의 **고위험 재인증 안내 위치 수정**. 모든 일반 화면에 늘 떠 있던 전역 배너를 없애고, 그 안내는 backend 가 고위험 요청을 실제로 거부한 바로 그 때 여는 재인증 창 안에만 둔다. 사용자 기능 추가가 아닌 운영자 화면 수정이므로 끝 버전 번호만 올린다 — Suite 0.2.0 → **0.2.1**. v0.1.1·v0.1.4·v0.2.0 의 모든 기준과 지시는 그대로 유효하며, 이 문서는 추가 요구만 적는다(v0.1.4 항목은 이번 구현 범위가 아니다). + +## 개정 이력 +- **v0.1.1 · Hera · 2026-10-26:** 최초 통합 개발 지시, Webmaker·AriMail 재사용 원칙, 사용량 경고 70·80·90·95%. +- **v0.1.4 · Neo · 2026-10-27:** patch 자동 증가 규칙, 미반영 오류 수정 전수 반영, HAL AI 대시보드, 다중 AI 공동 작업창. +- **v0.2.0 · Neo · 2026-10-28:** Webmaker 직접 편집(끌어다 놓기 시각 편집기) — Suite `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영. +- **v0.2.1 · Neo · 2026-10-29:** HAL 운영자 콘솔 고위험 재인증 안내를 전역 배너에서 거부 시점 재인증 창으로 옮김. 엄격한 TDD, 격리 작업 트리(`feature/hal-risk-banner-v0.2.1`, 기준 origin/main `0a56d4612c86dddfcbe644e775980d3c5b413b2e`), 커밋·push·병합·배포는 Neo 가 별도 검토 뒤 수행. + +## 1. 목표 +1. HAL 운영자 콘솔의 모든 일반 화면에 항상 보이던 다음 전역 배너를 **제거**한다: `! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증`. +2. 이 안내는 backend 가 고위험 작업 요청을 `reauth_required` 로 거부하여 재인증이 필요한 **바로 그 시점**에만, 재인증 창(modal) 안에서 보인다. +3. backend 보안 경계(`requireRecent`, CSRF, 3단계 확인, 고위험 작업 분류와 거부 동작)는 한 글자도 약화하지 않는다. +4. Suite 버전을 0.2.1 로 올리고 package/runtime/release 표기와 `VERSION_HISTORY.md` 를 일관되게 갱신한다. + +## 2. 사용자 요구(약화 금지) +- 운영자는 단순 화면 조회, 로그인 직후, 로그인 10분 경과, 일반 화면 이동 중에 고위험 재인증 안내를 **보지 않는다**. 안내는 실제로 고위험 작업을 시도해 서버가 거부한 순간에만 뜬다. +- 계정 화면의 **수동 계정 재인증** 단추와 동작은 유지한다. 수동 재인증 창에는 고위험 안내를 억지로 붙이지 않는다(비밀번호만 묻는다). +- 다른 전역 배너(접근권 만료 안내·HAL 자체 건강 비정상·긴급 복구 세션 진행·부트스트랩 미완료·비밀번호 변경 필요)는 그대로 둔다 — 배너 영역 자체를 없애는 것이 아니다. + +## 3. 보안 불변조건 +1. `hal/app.mjs` 의 `requireRecent`(최근 10분 재인증), CSRF 검사, 3단계 확인(`confirmImpact` → `confirmIdentifiers` → `confirmExecute`), 고위험 작업 분류(서버 삭제·격리·역할 변경, 모듈 되돌리기·배포·재조정, 작업 revert, 운영자 계정·접근권 발급, MFA 해제, 설정 변경, 메일 가져오기 적용·되돌리기, 릴리스 승인, 긴급 복구 선언·승인·자격, DR 설정·복원)과 403 `reauth_required` 거부 동작은 **변경 금지**. +2. 재인증 창을 **취소**하거나 재인증이 **실패**하면 원래 고위험 요청은 실행되지 않고, 원래 오류가 호출자에게 그대로 전파된다. +3. 재인증에 **성공**하면 원 요청을 기존처럼 **딱 한 번** 재시도한다. 재시도도 거부되면 다시 묻지 않고 오류를 올린다(무한 반복 금지). `reauth_required` 가 아닌 오류는 재인증을 묻지 않는다. +4. 판단은 전부 서버가 한다. 브라우저 쪽 변경은 "거부됐을 때 어떻게 묻고 몇 번 다시 보내는가"와 안내 문구 위치에 한정한다. 재인증 API(`POST /ops/api/reauth`)의 CSRF 요구도 그대로다. +5. 비밀번호·토큰·키·개인정보를 파일·출력·commit 에 넣지 않는다. + +## 4. TDD·검증 +- production code 보다 먼저 최소 회귀 테스트를 쓰고 실제로 실행해 RED(예상 이유로 실패)를 확인한 뒤 최소 구현으로 GREEN. 그 다음 targeted HAL UI/auth 테스트, 마지막에 전체 Suite 테스트(`node test/run.mjs`). +- 다뤄야 할 계약: (가) 일반 화면(운영 현황·서버·릴리스·작업·복구·중계 화면·계정)에 로그인 직후·10분 경과·화면 이동 모두 전역 고위험 배너 없음, 계정 화면 수동 재인증 단추 유지·고위험 안내 없음, 다른 배너 유지; (나) backend 경계 그대로 — 10분 경과 뒤 고위험 API 403 `reauth_required`·거부된 요청 미실행·CSRF 없는 재인증 거부·틀린 비밀번호 거부·재인증 뒤 통과·3단계 확인 별도 요구; (다) 재인증 창 문구 — 거부 시점에만 고위험 안내, 수동은 없음; (라) 취소 시 원 요청 미실행·오류 전파, 성공 시 딱 한 번 재시도, 재시도 거부 시 재질문 없음, 다른 오류는 재인증 질문 없음; (마) 정적 계약 — `hal/ui.mjs` 에 배너 코드 없음, `hal/ui/public/ops.js` 가 순수 helper 를 쓰고 안내 문구를 직접 품지 않음. +- 브라우저 스크립트는 DOM 없이 돌지 않으므로, 재인증 흐름·창 문구를 **테스트 가능한 순수 helper**(`hal/ui/public/reauth.mjs`)로 분리하고 ops.js 가 그것을 쓰는지는 정적 계약으로 못 박는다. 큰 dependency 를 더하지 않는다(외부 npm 의존성 0 유지). +- 기존 test runner 가 만드는 `docs/verification/local-e2e-*` 임시 파일은 최종 diff 에 남기지 않는다(이미 커밋된 2026-10-26·27 기록은 그대로). + +## 5. 범위·저장소 규율 +- 수정 허용: HAL UI(`hal/ui.mjs`, `hal/ui/render.mjs`(공개 사본은 심볼릭 링크), `hal/ui/public/ops.js`, 새 `hal/ui/public/reauth.mjs`), 관련 테스트, Suite 0.2.1 버전/이력(`package.json`·`README.md`·`VERSION_HISTORY.md`·버전을 검사하는 테스트), 이 개발 지시. +- 금지: Webmaker standalone 기능, 보안 경계, 승인 artifact 불변성(0.2.0 검토 수정), 다른 모듈. commit·push·merge·release build·운영 배포 금지 — Neo 가 독립 검토 뒤 수행한다. +- 코딩은 Claude Fable 5.1 로만 수행한다. 기존 v0.2.0 문서는 덮어쓰지 않는다. 파일 이름의 `Suitle` 철자는 식별자이므로 그대로 보존한다. + +## 6. 완료 조건(이번 구현의 인도 기준) +변경 파일 목록, RED 명령과 실패 이유, GREEN·targeted·전체 테스트의 실제 결과, 보안 결정, 남은 제한을 정확히 보고하고 프롬프트로 돌아온다. 구현 결과와 증거: `VERSION_HISTORY.md` 0.2.1 절(작성자·이전 버전·요구·수정한 기존 기능·운영자 화면·보안 결정·테스트·알려진 제한·미확정 정책값), 회귀 테스트 `test/hal-reauth-banner.test.mjs`. diff --git a/README.md b/README.md index b4b0a6e..a0abf3e 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ 고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다. 운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다. -- 버전: **0.2.0** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md) +- 버전: **0.2.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md) - 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md) - 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`) -- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) +- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) ## 구성 diff --git a/VERSION_HISTORY.md b/VERSION_HISTORY.md index 9e3a001..1e03154 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -1,5 +1,47 @@ # VERSION_HISTORY +## 0.2.1 (2026-10-29) + +- **작성자**: 개발 지시 — Neo(`Ariweb-Suitle_Dev-Order_v0.2.1.md`, 오즈 승인) · 구현 — Claude Fable 5.1(격리 작업 트리 `feature/hal-risk-banner-v0.2.1`, 기준 origin/main `0a56d4612c86dddfcbe644e775980d3c5b413b2e`). 커밋·push·병합·릴리스 빌드·운영 배포는 하지 않았다 — Neo 가 검토 뒤 수행한다. +- **이전 버전**: 0.2.0(2026-10-28, Webmaker 직접 편집 — 아래 절). 0.2.1 은 **끝 버전 번호만 올리는 운영자 화면 수정**이다: 사용자 기능·도입 모듈·보안 경계 변경 없음. +- **구성**: HAL · 고객 포털 · Node Agent · 도입 모듈 Webmaker 1.8.0 · AriMail 1.0.11 모두 0.2.0 그대로(HAL 운영자 콘솔 화면 2곳 + 브라우저 스크립트만 수정). 외부 npm 의존성 없음, Node 22.13+. + +### 요구(사용자 요구 — 약화 없이 반영) +HAL 운영자 콘솔의 모든 일반 화면 위에 재인증이 10분을 넘기면 늘 떠 있던 전역 배너 `! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증` 을 없앤다. 그 안내는 backend 가 고위험 요청을 실제로 `reauth_required` 로 거부한 **바로 그 시점**의 재인증 창 안에서만 보인다. 단순 화면 조회·로그인 직후·10분 경과·일반 이동 중에는 보이지 않는다. 계정 화면의 수동 재인증 단추·동작은 유지하되 고위험 안내를 붙이지 않는다. backend 의 `requireRecent`·CSRF·3단계 확인·고위험 분류·거부 동작은 그대로다. + +### 수정한 기존 기능 +- `hal/ui.mjs` `shell()`: 재인증 만료(`!session.recentlyAuthed`)를 전역 배너로 그리던 한 줄을 제거(이력 주석으로 대체). 다른 전역 배너(기기 귀속 접근권 만료 안내·HAL 자체 건강 비정상·긴급 복구 세션 진행·부트스트랩 미완료·비밀번호 변경 필요)와 `.alerts` 영역은 그대로. +- `hal/ui/public/ops.js`: `call()`(API 호출 + 재인증 재시도)과 `reauth()` 창이 새 순수 helper `hal/ui/public/reauth.mjs` 의 `callWithReauth`·`reauthPrompt` 를 쓴다. 서버가 `reauth_required` 로 거부했을 때 여는 재인증 창에만 고위험 안내(note, HTML 이스케이프)를 붙이고, 계정 화면의 수동 재인증(`data-act="reauth"`)은 `reauth()` 를 인자 없이 불러 안내 없이 비밀번호만 묻는다. 재시도 규칙은 전과 같다(성공 시 딱 한 번, 취소·실패 시 원 요청 미실행). +- `hal/ui/render.mjs` `accountsView`(공개 사본 `hal/ui/public/render.mjs` 는 심볼릭 링크): 계정 화면 "최근 재인증" 상태 배지 문구 `재인증 필요(고위험 작업)` → `만료(10분 지남)`. 조회 화면이 고위험 안내를 품지 않게 하는 중립 표기이며 상태 판정·단추는 같다. +- 새 파일 `hal/ui/public/reauth.mjs`(순수, DOM·fetch 없음, `/ops/ui/reauth.mjs` 로 정적 제공): `REAUTH_REQUIRED`·`HIGH_RISK_NOTICE`(옛 배너 문구 그대로 — 위치만 옮김)·`reauthPrompt({required})`·`callWithReauth(run, reauth)`. 브라우저와 Node 테스트가 같은 파일을 쓴다. +- 버전 표기: `package.json` 0.2.1(HAL 런타임 `hal/deps.mjs VERSION`·`scripts/release.mjs` 는 이 값을 따른다), `README.md` 버전·개발 지시 목록, `test/webmaker-visual-editor.test.mjs` 의 Suite 버전 검사 0.2.1. + +### 고객 화면 +- 변경 없음. + +### 운영자 화면 +- 일반 화면(운영 현황·고객·주문·상품·사이트·메일·도메인·서버·백업·이전·릴리스·작업·장애·감사·복구·계정)에서 고위험 재인증 전역 배너가 사라진다. 운영자는 고위험 작업(서버 삭제·격리·역할 변경, 모듈 되돌리기·배포·재조정, 작업 되돌리기, 운영자 추가·접근권 발급, MFA 해제, 설정 변경, 메일 가져오기 적용·되돌리기, 릴리스 승인, 긴급 복구 선언·승인·자격, DR 설정·복원)을 실제로 시도해 서버가 거부한 순간에만 "재인증(최근 10분)" 창 위쪽에서 `고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다` 를 본다. +- 계정 화면: "재인증" 단추 유지(수동 재인증 창은 비밀번호만), "최근 재인증" 행은 `유효(10분)` / `만료(10분 지남)`. 운영자 조치 항목 없음(설정값·보관소·DB 변경 없음). 배포 뒤 브라우저 캐시의 옛 `ops.js` 가 새 `reauth.mjs` 를 못 찾는 경우는 없다(옛 ops.js 는 helper 를 가져오지 않음). + +### 보안 결정 +- 서버 쪽 판단 코드는 한 줄도 바꾸지 않았다(`hal/app.mjs` `requireRecent`·CSRF·`threeStep`·고위험 경로 분류, `lib/session.mjs` `recentlyAuthed`, `POST /ops/api/reauth`). 변경은 "거부됐을 때 브라우저가 어떻게 묻고 몇 번 다시 보내는가"와 안내 문구 위치뿐이다. +- 재인증 창 취소·재인증 실패 → 원 요청을 다시 보내지 않고 원래 오류를 호출자에게 전파(고위험 작업이 몰래 실행되지 않음). 성공 → 같은 method·path·body 로 **딱 한 번** 재시도(`api` 직접 호출, `call` 재귀 아님). 재시도도 거부되면 다시 묻지 않는다(무한 반복 금지). `reauth_required` 외의 오류는 재인증을 묻지 않는다. +- 안내 문구는 helper 단일 출처이며 `R.esc` 로 이스케이프해 modal 에 넣는다. 비밀번호·토큰·키·개인정보는 파일·테스트·출력에 없다 — 새 테스트 `test/hal-reauth-banner.test.mjs` 의 운영자 비밀번호 fixture 는 정적 literal 없이 `node:crypto` `randomBytes` 로 실행마다 생성하며(정답·오답 값이 서로 다름 보장) 출력에도 찍지 않는다. + +### 테스트 +- RED(production 변경 전, `node --no-warnings test/hal-reauth-banner.test.mjs`): **통과 1 · 실패 4** — ① `/ops/: 10분 경과 뒤에도 고위험 배너 없음 (실제 false)`(배너가 아직 그려짐) ② ③ `ERR_MODULE_NOT_FOUND hal/ui/public/reauth.mjs`(helper 없음, 문구·재시도 계약 테스트 2건) ④ `hal/ui.mjs 는 고위험 배너 문구를 더 이상 그리지 않음 (실제 false)`(정적 계약). 통과 1건은 backend 경계 보호 테스트(변경 대상이 아니므로 처음부터 통과해야 한다). +- GREEN(최소 구현 뒤): `test/hal-reauth-banner.test.mjs` **5/5**; targeted `test/hal-ui.test.mjs` **7/7**, `test/hal.test.mjs` **16/16**(기존 reauth_required → 재인증 → 통과 흐름 포함). `node --check` 로 `ops.js`·`reauth.mjs` 구문 확인. +- 전체 Suite `node test/run.mjs`: **25개 파일 실패 0**(새 `hal-reauth-banner` 5 포함, 로컬 E2E 11 통과; 러너가 만든 `docs/verification/local-e2e-2026-10-28.*` 임시 파일은 diff 에서 제거). 기존 테스트 변경은 `test/webmaker-visual-editor.test.mjs` 의 Suite 버전 검사 0.2.1 뿐. +- 새 테스트가 못 박는 계약: 일반 화면 6곳 + 계정 화면에 로그인 직후·10분 경과 모두 전역 배너·배너 영역 재인증 링크·`고위험 작업(` 문구 없음, 계정 수동 재인증 단추 유지, 부트스트랩 배너 유지 / backend: 10분 경과 뒤 서버 삭제·격리 403 `reauth_required`(`detail.action=reauth`)·거부된 요청 미실행·CSRF 없는 재인증 403 `csrf`·틀린 비밀번호 거부·재인증 뒤 `recentlyAuthed`·3단계 확인 없이는 여전히 거부 / helper: 수동 창 note 없음·거부 창 note = 옛 배너 문구·취소 시 run 1회·오류 전파·성공 시 run 2회·재시도 거부 시 재질문 없음·다른 오류는 재인증 없음·정상 응답 그대로 / 정적: `hal/ui.mjs` 배너 코드 없음, `ops.js` 가 `./reauth.mjs`·`callWithReauth`·`reauthPrompt` 를 쓰고 `reauth: () => reauth()`(인자 없음)이며 문구를 직접 품지 않음. + +### 알려진 제한 +- 실제 브라우저(DOM)에서 재인증 modal 이 열리는 모습은 자동 테스트하지 않았다(저장소에 브라우저 러너 없음, 큰 dependency 추가 금지). 흐름·문구는 순수 helper 로, 결선은 정적 계약으로 검사했다. 배포 뒤 운영자가 10분 경과 상태에서 고위험 단추(예: 서버 격리) 하나를 눌러 안내가 붙은 재인증 창이 뜨고 취소 시 실행되지 않음을 한 번 눈으로 확인하는 것을 권한다. +- `hal/ui/render.mjs` `relayError({code:'reauth_required'})` 가 포털 중계 응답에 "재인증 필요" 상태 패널을 그리는 동작은 그대로다(이것도 서버가 거부한 그 시점의 표시이며 고위험 문구는 없다). +- `docs/verification/` 에 0.2.1 전용 검증 문서는 만들지 않았다(지시 범위: HAL UI·테스트·버전/이력·개발 지시). RED/GREEN 증거는 이 절과 인도 보고에 있다. + +### 미확정 정책값 +- 0.2.0 의 미확정 정책값 그대로. 재인증 유효 시간(10분)·고위험 작업 분류는 바꾸지 않았고 이번 범위 밖이다. + ## 0.2.0 (2026-10-28) - **작성자**: 개발 지시 — Neo(`Ariweb-Suitle_Dev-Order_v0.2.0.md`) · 구현 — Claude Fable 5.1(격리 작업 트리 `feature/visual-editor-v0.2.0`, 기준 main `4b3714e37c543fcfa8862dbc68c76f62a9b0bf97`). 커밋·push·병합·배포·운영 서버 접근은 하지 않았다 — Neo 가 검토 뒤 수행한다. diff --git a/hal/ui.mjs b/hal/ui.mjs index a196bfe..2441689 100644 --- a/hal/ui.mjs +++ b/hal/ui.mjs @@ -7,6 +7,7 @@ * - HAL 자체 데이터(서버·릴리스·작업·사건·감사·긴급 복구·재해복구·계정)는 서버에서 먼저 그린다(hal/ui/render.mjs). 포털 데이터(고객·주문·상품·사이트·메일·도메인·백업·이전)는 * 브라우저가 /ops/api/portal/* 중계로 받아 같은 렌더러로 그리고, 중계 실패는 '정보 지연'으로 보인다(정상으로 보이지 않음). * - 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음 상태와 360px 사용을 지원한다(hal/ui/public/ops.css). + * - 전역 배너(.alerts)는 접근권 만료·HAL 비정상·긴급 복구·부트스트랩 미완료·비밀번호 변경만 그린다. 고위험 재인증 안내는 0.2.1 부터 거부 시점의 재인증 창 안에만 둔다(hal/ui/public/reauth.mjs). */ import { esc } from '../lib/http.mjs'; import * as session from '../lib/session.mjs'; @@ -39,10 +40,10 @@ export function registerUi(app, d) { /** 공통 틀. 운영자 세션이 없으면 로그인 화면(또는 최초 설정 화면)을 낸다. */ function shell(ctx, { title, nav, body, page, id = '', scripts = [] }) { if (!ctx.operator) return ctx.html(loginPage(ctx, d)); - const recent = session.recentlyAuthed(ctx.session); const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null; + const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null; const health = core.selfHealth(d); const emOpen = d.store.get('SELECT COUNT(*) AS n FROM emergency_sessions WHERE status<>\'closed\'').n; const banner = []; - if (!recent) banner.push(`! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증`); + // [0.2.1] 재인증 만료(10분 경과)는 더 이상 전역 배너로 그리지 않는다. 고위험 안내는 서버가 요청을 reauth_required 로 거부한 그 때 ops.js 가 여는 재인증 창(hal/ui/public/reauth.mjs) 안에만 보인다. 거부 자체(requireRecent)는 그대로다. if (grant) banner.push(`
ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)
`); if (!health.ok) banner.push(`
⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요
`); if (emOpen) banner.push(`⚠ 긴급 복구 세션 ${emOpen}건 진행 중`); diff --git a/hal/ui/public/ops.js b/hal/ui/public/ops.js index f100327..5564df1 100644 --- a/hal/ui/public/ops.js +++ b/hal/ui/public/ops.js @@ -1,9 +1,11 @@ /* hal/ui/public/ops.js — 운영자 콘솔 브라우저 스크립트. - * - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 비밀번호 창 → 재시도, 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내 + * - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내 + * - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다. * - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다 * - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다. */ import * as R from './render.mjs'; +import { callWithReauth, reauthPrompt } from './reauth.mjs'; const $ = (s, el = document) => el.querySelector(s); const csrf = () => ($('meta[name=csrf]') || {}).content || ''; async function api(method, path, body) { @@ -12,8 +14,8 @@ async function api(method, path, body) { if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; } return j; } -/** 재인증이 필요하면 비밀번호를 받아 재인증한 뒤 한 번 더 시도 */ -async function call(method, path, body) { try { return await api(method, path, body); } catch (e) { if (e.code === 'reauth_required') { if (await reauth()) return api(method, path, body); } throw e; } } +/** 서버가 reauth_required 로 거부하면 고위험 안내가 붙은 재인증 창을 열고, 성공했을 때만 딱 한 번 다시 시도(취소·실패 시 원 요청 미실행 — hal/ui/public/reauth.mjs 계약) */ +const call = (method, path, body) => callWithReauth(() => api(method, path, body), reauth); const portal = (method, path, body) => call(method, '/ops/api/portal' + path, body); let toastT; function toast(t) { const el = $('#toast'); if (!el) return alert(t); el.textContent = t; el.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove('show'), 5000); } function modal(html) { const m = $('#modal'); m.innerHTML = ``; m.hidden = false; const close = () => { m.hidden = true; m.innerHTML = ''; }; m.onclick = (e) => { if (e.target === m) close(); }; return { el: m, close }; } @@ -37,7 +39,8 @@ function threeStep({ title, impacts = [], identifiers = [], revertible = '되돌 render(); }); } -async function reauth() { const v = await ask('재인증(최근 10분)', [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: '재인증' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } } +/** 재인증 창. required=true(서버 거부 직후)일 때만 고위험 안내를 보이고, 계정 화면의 수동 재인증은 안내 없이 비밀번호만 묻는다 */ +async function reauth(opts = {}) { const p = reauthPrompt(opts); const v = await ask(p.title, [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: p.okLabel, note: p.note ? R.esc(p.note) : '' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } } const reload = () => location.reload(); async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message || String(e)); } } // ---------------- 페이지별 데이터 로드(포털 중계 포함) diff --git a/hal/ui/public/reauth.mjs b/hal/ui/public/reauth.mjs new file mode 100644 index 0000000..5930b4e --- /dev/null +++ b/hal/ui/public/reauth.mjs @@ -0,0 +1,39 @@ +/** + * hal/ui/public/reauth.mjs — 운영자 콘솔 재인증 흐름의 순수 계약(0.2.1, 개발 지시 `Ariweb-Suitle_Dev-Order_v0.2.1.md`). + * + * 브라우저(ops.js)와 Node 테스트가 같은 파일을 쓴다. DOM·fetch 를 직접 만지지 않고, 호출자가 넘긴 함수만 부른다. + * + * 왜 따로 두나 + * - 0.2.0 까지는 재인증이 10분을 넘기면 모든 일반 화면 위에 "고위험 작업(…)은 최근 10분 안의 재인증이 필요합니다" 전역 배너가 늘 떠 있었다. + * 단순 조회·화면 이동·로그인 10분 뒤에도 보여 운영자가 경고에 무뎌진다. 0.2.1 은 이 안내를 backend 가 실제로 고위험 요청을 + * `reauth_required` 로 거부한 **바로 그 때 여는 재인증 창 안**으로만 옮긴다. 수동 재인증(계정 화면 단추)에는 붙이지 않는다. + * - 보안 경계는 그대로다: 거부·재인증 판단은 전부 서버(hal/app.mjs requireRecent·CSRF·3단계 확인)가 하고, 여기는 "거부됐을 때 어떻게 묻고 몇 번 다시 보내는가"만 정한다. + */ + +/** 서버가 고위험 요청을 거부할 때 쓰는 오류 코드(hal/app.mjs requireRecent 와 같은 값) */ +export const REAUTH_REQUIRED = 'reauth_required'; +/** 거부 시점 재인증 창에만 보이는 고위험 안내(옛 전역 배너 문구 그대로 — 위치만 옮겼다) */ +export const HIGH_RISK_NOTICE = '고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다'; + +/** + * 재인증 창의 문구. required=true(서버 거부 직후)일 때만 고위험 안내(note)를 붙이고, 수동 재인증(required 없음)은 안내 없이 비밀번호만 묻는다. + * @returns {{ title: string, okLabel: string, note: string }} + */ +export function reauthPrompt({ required = false } = {}) { + return { title: '재인증(최근 10분)', okLabel: '재인증', note: required ? HIGH_RISK_NOTICE : '' }; +} + +/** + * 요청을 보내고, 서버가 `reauth_required` 로 거부하면 재인증을 묻고(required=true) 성공했을 때만 **딱 한 번** 다시 보낸다. + * - 재인증 창을 취소하거나 재인증이 실패하면 원 요청을 다시 보내지 않고 원래 오류를 그대로 올린다(고위험 작업이 몰래 실행되지 않음). + * - 재시도도 거부되면 다시 묻지 않고 오류를 올린다(무한 반복 금지). + * - `reauth_required` 가 아닌 오류(권한 없음·검증 실패·네트워크)는 재인증을 묻지 않는다. + * @param {() => Promise} run 실제 요청(같은 method·path·body 로 다시 부를 수 있어야 한다) + * @param {(opts: { required: true }) => Promise} reauth 재인증 창을 열어 성공 여부를 돌려주는 함수 + */ +export async function callWithReauth(run, reauth) { + try { return await run(); } catch (e) { + if (e && e.code === REAUTH_REQUIRED && await reauth({ required: true })) return run(); + throw e; + } +} diff --git a/hal/ui/render.mjs b/hal/ui/render.mjs index ed8260e..8fa8b2f 100644 --- a/hal/ui/render.mjs +++ b/hal/ui/render.mjs @@ -122,7 +122,7 @@ export function drView(data) {

복구 훈련

${table([{ l: '훈련', f: (x) => `${esc(x.id)}` }, { l: '시작·종료', f: (x) => `${fmt(x.started_at)} → ${fmt(x.finished_at)}` }, { l: '결과', f: (x) => x.result === 'running' ? badge('running', '진행 중') + ` ` : x.result === 'success' ? badge('ok', '성공') : badge('failed', x.result) }, { l: '측정 RTO/RPO', f: (x) => `${x.measured_rto_minutes ?? '—'} / ${x.measured_rpo_minutes ?? '—'}분` }, { l: '실패·조치 항목', f: (x) => x.findings_json ? `
${esc(x.findings_json)}
` : '—' }], data.drills || [], { empty: '훈련 기록이 없습니다' })}`; } export function accountsView(data) { - return `

내 계정

${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` ${esc(data.me.securityHint || '')}`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '재인증 필요(고위험 작업)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}
${data.me.mfaEnabled ? `` : ``}
+ return `

내 계정

${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` ${esc(data.me.securityHint || '')}`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '만료(10분 지남)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}
${data.me.mfaEnabled ? `` : ``}

로그인 세션

${table([{ l: '기기', f: (s) => `${esc(s.label || '')}${s.current ? ' (현재)' : ''}` }, { l: 'IP', k: 'ip' }, { l: '마지막 사용', f: (s) => fmt(s.last_seen_at) }, { l: '', f: (s) => s.current ? '' : `` }], data.sessions || [])}

접근권(허용 네트워크 밖 기기)

${table([{ l: '계정', k: 'account_id' }, { l: '발급', f: (g) => `${esc(g.created_by || '')} ${fmt(g.created_at)}` }, { l: '만료', f: (g) => fmt(g.expires_at) }, { l: '기기 묶임', f: (g) => g.bound_at ? `${fmt(g.bound_at)} (${esc(g.ip || '')})` : '아직' }, { l: '상태', f: (g) => g.revoked_at ? badge('neutral', '회수') : Date.parse(g.expires_at) < Date.now() ? badge('neutral', '만료') : badge('ok', '유효') }, { l: '사유', k: 'reason' }, { l: '', f: (g) => g.revoked_at ? '' : `` }], data.grants || [], { empty: '발급된 접근권이 없습니다' })}

운영자 계정

${table([{ l: '아이디', f: (a) => `${esc(a.login)} ${esc(a.displayName)}` }, { l: '보안', f: (a) => a.mfaEnabled ? badge('ok', 'MFA') : badge('warn', '비밀번호만') }, { l: '상태', f: (a) => a.lockedUntil ? badge('warn', `잠김 ${fmt(a.lockedUntil)}`) : badge('ok', a.status) }, { l: '마지막 로그인', f: (a) => fmt(a.lastLoginAt) }], data.accounts || [])}
`; diff --git a/package.json b/package.json index 979d487..465735c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ariweb-suite", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.", "type": "module", diff --git a/test/hal-reauth-banner.test.mjs b/test/hal-reauth-banner.test.mjs new file mode 100644 index 0000000..de12e2a --- /dev/null +++ b/test/hal-reauth-banner.test.mjs @@ -0,0 +1,121 @@ +/** + * 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 의 전역 배너 영역(
…
)만 떼어 낸다 */ +const alerts = (html) => (html.match(/
([\s\S]*?)<\/div>
/) || ['', ''])[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('고위험 작업'), '계정 화면에 고위험 안내 없음'); + // 다른 배너(부트스트랩 미완료)는 그대로 — 배너 영역 자체를 없앤 것이 아님 + ok(alerts(r.text).includes('부트스트랩 미완료'), '다른 전역 배너는 유지'); +}); + +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(); diff --git a/test/webmaker-visual-editor.test.mjs b/test/webmaker-visual-editor.test.mjs index 4bf420f..c201c1d 100644 --- a/test/webmaker-visual-editor.test.mjs +++ b/test/webmaker-visual-editor.test.mjs @@ -19,10 +19,10 @@ const require = createRequire(import.meta.url); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const WM = join(ROOT, 'modules', 'webmaker'); -await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.0 이다', () => { +await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.1(HAL 재인증 안내 위치 수정, 도입 모듈 변경 없음) 이다', () => { for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f); eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0'); - eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.2.0'); + eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.2.1'); const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker'); const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin'); if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');