diff --git a/docs/verification/README.md b/docs/verification/README.md new file mode 100644 index 0000000..d3501ef --- /dev/null +++ b/docs/verification/README.md @@ -0,0 +1,38 @@ +# 검증 기록(docs/verification) + +이 디렉터리에는 Ariweb Suite 의 **로컬 종단 간 검증 하네스**(`test/e2e/`)가 만든 기록이 쌓인다. 개발 지시 "Release 0.1.1 완료 조건" 34개 흐름을 +실제 HAL·고객 포털·Node Agent 프로세스로 걸어 보고, 흐름마다 다음 판정과 증거(HTTP 상태·최종 화면 상태·작업 단계)를 남긴다. + +| 판정 | 뜻 | +|---|---| +| 로컬 확인(verified_local) | 로컬 환경에서 끝까지 확인됨 | +| 부분 확인(partial) | 일부 단계만 확인됨(나머지는 실 인프라가 필요하거나 선행 흐름의 결함으로 막힘) | +| 로컬 확인 불가(not_verifiable_locally) | 실 DNS·메일 왕복·공인 서버 등이 필요 | +| 실패(결함)(failed) | 실제 결함으로 보이는 실패 — 보고서의 "발견한 결함" 에 재현 정보 | + +**이 기록은 출시 판정이 아니다.** 개발 지시는 각 흐름을 실제 배포 환경에서 최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전으로 판정하도록 요구한다. +로컬 하네스는 결함을 빨리 찾고, 실서버 검증 전에 어떤 흐름이 어디까지 동작하는지 드러내는 용도다. + +## 실행 + +```bash +node --no-warnings test/e2e.test.mjs # 전체(5분 안에 끝남). docs/verification/local-e2e-<날짜>.md/.json 생성 +ARIWEB_E2E_FLOWS=30-sites,80-hal node --no-warnings test/e2e.test.mjs # 흐름 그룹 일부만 +ARIWEB_E2E_STRICT=1 node --no-warnings test/e2e.test.mjs # failed 판정이 있으면 종료 코드 1 +ARIWEB_E2E_KEEP=1 … # 임시 디렉터리(test/.tmp/e2e-*) 보존 +ARIWEB_E2E=0 … # 건너뜀(test/run.mjs 에서 제외하고 싶을 때) +``` + +## 하네스 구성(test/e2e/) + +- `boot.mjs` — 빈 포트·임시 데이터 디렉터리로 가짜 서비스 → HAL(설정 토큰·릴리스 등록·부트스트랩 → 서버 실행 → 운영자 최초 설정·로그인) → Node Agent 4대(control/web/backup/web, `ARIWEB_AGENT_STUB_CMDS=1` 로 systemctl/nginx/certbot 은 기록만, tar 와 파일 배치는 실제) → 고객 포털(Catalog 자동 시드, AriMail 서버 등록) 순으로 띄운다. +- `fake-mail.mjs` — Resend 호환 발신 API 포획(가입 확인·초대·필수 알림 메일에서 토큰·링크를 꺼낸다). +- `fake-webmaker.mjs` — Webmaker 의 포털 연동 면(`/api/internal/ariweb/*` 서명 검증, 프로젝트·저장본·export tar.gz, `/api/auth/sso` 1회용 티켓). +- `fake-arimail.mjs` — AriMail 의 설정·관리자·사용량 API(도메인별 보장량·경고 구간·제한 사유 계산). SMTP/IMAP 은 없다. +- `flows/*.mjs` — 흐름 그룹(번호순 실행). 각 단계는 `steps().run()` 으로 증거를 남기며 결함이 보이면 `report.bug()` 로 재현 정보를 적는다. 결함을 우회하지 않고, 막힌 뒤 흐름은 `partial`(선행 흐름 번호) 로 적는다. +- `report.mjs` — 흐름·결함·실 인프라 필요 항목을 Markdown/JSON 으로 쓴다. 비밀값은 lib/redact 로 가린다. + +## 로컬에서 확인할 수 없는 것(실서버·실 DNS 필요) + +도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인, 메일 DNS 와 외부 송신·수신·회신 왕복, 실제 서버 간 이전·백업 저장소, 모듈 설치(/srv/ariweb 권한), 재해복구의 실제 다른 호스트 복원. +이 항목들은 하네스가 `not_verifiable_locally` 또는 `partial` 로 적고 "실제 인프라가 필요한 항목" 절에 모은다. diff --git a/docs/verification/local-e2e-2026-10-26.json b/docs/verification/local-e2e-2026-10-26.json new file mode 100644 index 0000000..9e9eceb --- /dev/null +++ b/docs/verification/local-e2e-2026-10-26.json @@ -0,0 +1,2870 @@ +{ + "generatedAt": "2026-10-26T13:02:10.786Z", + "startedAt": "2026-10-26T12:57:29.622Z", + "env": { + "agents": 4, + "ports": { + "hal": 35361, + "portal": 35287, + "wm": 43619, + "am": 33137, + "mail": 40085 + }, + "mailServerRegistered": true, + "node": "v24.22.1" + }, + "summary": { + "verified_local": 13, + "partial": 15, + "not_verifiable_locally": 2, + "failed": 4, + "unrecorded": 0 + }, + "flows": [ + { + "no": 1, + "title": "신규 고객 가입과 이메일 확인", + "verdict": "verified_local", + "note": "가입→확인 메일(포획)→1회용 토큰 확인→개인 그룹·별칭→중복 가입 안내→기기 목록·세션 종료", + "blockedBy": null, + "steps": [ + { + "name": "가입(이메일·표시 이름·비밀번호·필수 동의) → 세션 발급", + "ok": true, + "detail": { + "status": 200, + "hasSession": "[redacted]" + } + }, + { + "name": "확인 전 유료·운영 기능 차단(그룹 생성 403 email_unverified)", + "ok": true, + "detail": "email_unverified" + }, + { + "name": "확인 메일 수신(발신 포획)에서 토큰 추출", + "ok": true, + "detail": { + "subject": "[Ariweb] 이메일 주소를 확인해 주세요" + } + }, + { + "name": "이메일 확인(1회용) → 재사용 거부", + "ok": true, + "detail": { + "first": 200, + "second": "token_used" + } + }, + { + "name": "내 정보: 이메일 확인됨 · 개인 그룹 · owner · 별칭 user-01", + "ok": true, + "detail": { + "verified": true, + "groupKind": "personal", + "role": "owner", + "alias": "user-01" + } + }, + { + "name": "중복 가입은 존재를 드러내지 않는 안내(409 signup_unavailable)", + "ok": true, + "detail": "signup_unavailable" + }, + { + "name": "로그인 기기 목록 · 다른 세션 종료", + "ok": true, + "detail": { + "sessionsBefore": "[redacted]", + "revoked": 2 + } + } + ], + "evidence": { + "me": { + "verified": true, + "groupKind": "personal", + "role": "owner", + "alias": "user-01" + } + } + }, + { + "no": 2, + "title": "개인 사용자 그룹 생성과 다른 사용자 초대", + "verdict": "verified_local", + "note": "팀 그룹 생성·초대 메일·1회용 수락·별칭 user-02·역할 거부·그룹 전환 격리", + "blockedBy": null, + "steps": [ + { + "name": "팀 그룹 만들기 → 현재 그룹 전환", + "ok": true, + "detail": "g_01a17849a2c2dec27ee9f14777" + }, + { + "name": "이메일 초대(7일) → 초대 메일 링크", + "ok": true, + "detail": "RAXi6X0pnmr-nCEKiIX63pk26vTbIB5d" + }, + { + "name": "초대 수락(1회용) → 별칭 user-02 · 역할 member", + "ok": true, + "detail": { + "alias": "user-02", + "role": "member", + "reuse": "invite_invalid" + } + }, + { + "name": "권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할", + "ok": true, + "detail": { + "requiredRole": [ + "owner", + "admin" + ], + "yourRole": "member" + } + }, + { + "name": "그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음", + "ok": true, + "detail": { + "team": { + "live": 0, + "purchased": 0, + "inUse": 0, + "reserved": 0, + "unallocated": 0 + }, + "personal": { + "live": 0, + "purchased": 0, + "inUse": 0, + "reserved": 0, + "unallocated": 0 + } + } + } + ], + "evidence": {} + }, + { + "no": 3, + "title": "상품 주문, 결제 확인, 권리 활성화", + "verdict": "verified_local", + "note": "상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면", + "blockedBy": null, + "steps": [ + { + "name": "상품 목록(공개 버전만·가격·한도·미확정 정책 표시)", + "ok": true, + "detail": { + "count": 2, + "team": { + "id": "pv_01a17849a127747d9c2bbbb24c", + "price": 49000, + "sites": 3 + }, + "deferred": 3, + "paymentMethods": [ + "manual_transfer" + ] + } + }, + { + "name": "주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음", + "ok": true, + "detail": { + "orderNo": "AW-20261026-0001", + "status": "awaiting_payment", + "total": 63800, + "dueAt": "2026-11-02T12:57:34.263Z", + "hasInstructions": true + } + }, + { + "name": "입금 확인 전 승인 거부(409 payment_not_confirmed)", + "ok": true, + "detail": "payment_not_confirmed" + }, + { + "name": "운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시", + "ok": true, + "detail": { + "status": "awaiting_approval", + "delta": [ + "sites", + "web_disk", + "web_out", + "mail_storage", + "mail_msgs", + "mailboxes", + "mail_services", + "mail_storage", + "mail_msgs", + "mailboxes" + ] + } + }, + { + "name": "운영자 승인 → 권리 활성화(재승인은 중복 없음)", + "ok": true, + "detail": { + "granted": 10, + "already": true + } + }, + { + "name": "고객 결제 화면: 활성 상품·권리·다음 청구일", + "ok": true, + "detail": { + "sites": 3, + "subs": 1, + "nextBilling": "2026-11-26T12:57:34.327Z" + } + } + ], + "evidence": { + "ap": { + "granted": 10, + "already": true + }, + "bill": { + "sites": 3, + "subs": 1, + "nextBilling": "2026-11-26T12:57:34.327Z" + } + } + }, + { + "no": 4, + "title": "초안 사이트 생성", + "verdict": "verified_local", + "note": "초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터", + "blockedBy": null, + "steps": [ + { + "name": "사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내", + "ok": true, + "detail": { + "id": "s_01a17849a3be6f07d129dbdb60", + "lifecycle": "draft", + "projectId": "00000000000000000000000000000001", + "nextSteps": [ + "build", + "import", + "domain", + "mode", + "perms" + ] + } + }, + { + "name": "사이트 목록 필터(초안)·사용량 요약에 예약 0", + "ok": true, + "detail": { + "drafts": 1, + "sitesReserved": 0, + "sitesAllocated": 0 + } + } + ], + "evidence": {} + }, + { + "no": 5, + "title": "Webmaker에서 페이지 제작", + "verdict": "verified_local", + "note": "SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음)", + "blockedBy": null, + "steps": [ + { + "name": "제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)", + "ok": true, + "detail": { + "portalRedirect": "http://127.0.0.1:43619/api/auth/sso?ticket=…", + "webmaker": 302, + "reuse": 401 + } + }, + { + "name": "Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과", + "ok": true, + "detail": { + "versions": 1, + "precheckOk": true + } + } + ], + "evidence": { + "sso": { + "portalRedirect": "http://127.0.0.1:43619/api/auth/sso?ticket=…", + "webmaker": 302, + "reuse": 401 + }, + "ver": { + "versions": 1, + "precheckOk": true + } + } + }, + { + "no": 6, + "title": "관리형 배포", + "verdict": "failed", + "note": "시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: [\"failed\",\"Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni\"])", + "blockedBy": null, + "steps": [ + { + "name": "배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나", + "ok": true, + "detail": { + "jobId": "j_01a17849a4096e33f476b17d1b", + "steps": 10, + "again": false, + "siteCount": "reserved", + "server": null + } + }, + { + "name": "HAL·Agent 를 거쳐 작업 완료 대기(최대 40초)", + "ok": false, + "detail": "시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: [\"failed\",\"Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni\"])" + }, + { + "name": "[HAL 직접] 같은 Agent 에 로컬 저장본으로 site.deploy → 성공하면 결함은 포털→Agent 번들 전달 구간", + "ok": true, + "detail": { + "status": "done", + "error": null, + "layout": true, + "externalCheck": { + "ok": false, + "status": 404, + "ms": 19, + "url": "http://direct_e2e.preview.localhost/", + "markerFound": false + } + } + }, + { + "name": "사용자 취소(진행 중 작업) → 중간 결과 정리·예약 반환", + "ok": true, + "detail": { + "cancel": 200, + "cleanup": 200, + "siteCount": "reserved", + "lifecycle": "building" + } + } + ], + "evidence": { + "halLog": [ + "2026-10-26T12:58:01.231Z [jobs] site.deploy j_01a1784a0781bd4cb2d160762e 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", + "2026-10-26T12:58:06.242Z [jobs] site.deploy j_01a1784a1b1b6362b7ba3a604c 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", + "2026-10-26T12:58:11.248Z [jobs] site.deploy j_01a1784a2eb913f75494650303 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni" + ] + } + }, + { + "no": 7, + "title": "도메인 연결과 HTTPS 발급", + "verdict": "partial", + "note": "도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가", + "blockedBy": null, + "steps": [ + { + "name": "도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)", + "ok": true, + "detail": { + "domainId": "d_01a1784a491476e7f378e8217c", + "txtName": "_ariweb-verify.aw-e2e.localhost", + "txtValueShape": "ariweb-verify=…", + "copyable": true + } + }, + { + "name": "도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시", + "ok": true, + "detail": {} + }, + { + "name": "(정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다", + "ok": true, + "detail": { + "queuedProbes": 1 + } + } + ], + "evidence": { + "dm": { + "domainId": "d_01a1784a491476e7f378e8217c", + "txtName": "_ariweb-verify.aw-e2e.localhost", + "txtValueShape": "ariweb-verify=…", + "copyable": true + } + } + }, + { + "no": 8, + "title": "외부 주소에서 사이트 확인", + "verdict": "not_verifiable_locally", + "note": "외부 주소 확인은 공인 서버·DNS 필요", + "blockedBy": 6, + "steps": [], + "evidence": {} + }, + { + "no": 9, + "title": "사이트 정보와 접속·사용량 통계 확인", + "verdict": "partial", + "note": "미배포 사이트는 통계 대신 준비 단계 표시(§8.1) 확인. 실제 접속·네트워크 통계는 배포 뒤 수집", + "blockedBy": 6, + "steps": [ + { + "name": "개요: 배포 전/후에 따라 준비 단계 또는 실제 통계", + "ok": true, + "detail": { + "kind": "not_deployed", + "lifecycle": "building", + "hasStats": false, + "readiness": [ + { + "id": "build", + "label": "Webmaker 에서 사이트 제작", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", + "done": true + }, + { + "id": "import", + "label": "콘텐츠 가져오기", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", + "done": false, + "unsupported": "이번 버전에서 지원하지 않음" + }, + { + "id": "domain", + "label": "도메인 연결", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=domains", + "done": false + }, + { + "id": "mode", + "label": "배포 방식 선택", + "href": "/sites/s_01a17849a3be6f07d129dbdb60/deploy/mode", + "done": true + }, + { + "id": "perms", + "label": "구성원 권한 설정", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=settings", + "done": false + } + ], + "disk": null + } + }, + { + "name": "사용량 탭(배정·실제 사용·마지막 계측)", + "ok": true, + "detail": { + "allocations": [ + "web_disk", + "web_out" + ] + } + } + ], + "evidence": { + "ov": { + "kind": "not_deployed", + "lifecycle": "building", + "hasStats": false, + "readiness": [ + { + "id": "build", + "label": "Webmaker 에서 사이트 제작", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", + "done": true + }, + { + "id": "import", + "label": "콘텐츠 가져오기", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", + "done": false, + "unsupported": "이번 버전에서 지원하지 않음" + }, + { + "id": "domain", + "label": "도메인 연결", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=domains", + "done": false + }, + { + "id": "mode", + "label": "배포 방식 선택", + "href": "/sites/s_01a17849a3be6f07d129dbdb60/deploy/mode", + "done": true + }, + { + "id": "perms", + "label": "구성원 권한 설정", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=settings", + "done": false + } + ], + "disk": null + }, + "us": { + "allocations": [ + "web_disk", + "web_out" + ] + } + } + }, + { + "no": 10, + "title": "사이트 대표 메일과 메일함 생성", + "verdict": "partial", + "note": "대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요", + "blockedBy": null, + "steps": [ + { + "name": "사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시", + "ok": true, + "detail": { + "id": "msv_01a1784ad1a3c31d4ea6cb7447", + "status": "needs_setup", + "tenant": true, + "ariwebKind": "site_bound", + "limitAccounts": 6, + "siteCountAllocated": 0 + } + }, + { + "name": "같은 사이트에 두 번째 대표 메일 거부", + "ok": true, + "detail": "mail_domain_in_use" + }, + { + "name": "메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음", + "ok": true, + "detail": { + "id": "mb_01a1784ad1df7ea80529a8cf8a", + "address": "kim@aw-e2e.localhost", + "passwordShownOnce": "[redacted]", + "mustChange": true, + "imapHost": "127.0.0.1", + "leakedOnReload": false, + "inArimail": true + } + }, + { + "name": "초대 방식 메일함(링크 만료·재발송·사용 완료 상태)", + "ok": true, + "detail": { + "inviteState": "pending", + "accepted": 200, + "reuse": "invite_used" + } + }, + { + "name": "DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing)", + "ok": true, + "detail": { + "ok": false, + "missing": 3, + "items": [ + "mx:missing", + "spf:missing", + "dkim:missing", + "dmarc:recommended" + ] + } + }, + { + "name": "활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등", + "ok": true, + "detail": { + "status": "awaiting_external", + "waitReason": "DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요", + "sameJob": true, + "steps": [ + "entitlement:done", + "dns:waiting", + "prepare:pending", + "verify:pending", + "activate:pending" + ] + } + }, + { + "name": "보안 보고서(SPF·DKIM·DMARC·MX 상태 + 복사 값)", + "ok": true, + "detail": { + "items": [ + "sender:error", + "mx:error", + "spf:error", + "dkim:error", + "dmarc:recommended", + "tls:error", + "roundtrip:recommended" + ], + "records": 4 + } + } + ], + "evidence": { + "svc": { + "id": "msv_01a1784ad1a3c31d4ea6cb7447", + "status": "needs_setup", + "tenant": true, + "ariwebKind": "site_bound", + "limitAccounts": 6, + "siteCountAllocated": 0 + }, + "mb": { + "id": "mb_01a1784ad1df7ea80529a8cf8a", + "address": "kim@aw-e2e.localhost", + "passwordShownOnce": "[redacted]", + "mustChange": true, + "imapHost": "127.0.0.1", + "leakedOnReload": false, + "inArimail": true + }, + "dns": { + "ok": false, + "missing": 3, + "items": [ + "mx:missing", + "spf:missing", + "dkim:missing", + "dmarc:recommended" + ] + }, + "act": { + "status": "awaiting_external", + "waitReason": "DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요", + "sameJob": true, + "steps": [ + "entitlement:done", + "dns:waiting", + "prepare:pending", + "verify:pending", + "activate:pending" + ] + } + } + }, + { + "no": 11, + "title": "외부 송신, 수신, 회신 확인", + "verdict": "not_verifiable_locally", + "note": "외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨", + "blockedBy": null, + "steps": [], + "evidence": {} + }, + { + "no": 12, + "title": "독립 메일 서비스 생성", + "verdict": "verified_local", + "note": "독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구", + "blockedBy": null, + "steps": [ + { + "name": "메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)", + "ok": true, + "detail": { + "id": "d_01a1784af801ae5cf98ef55e1f", + "jobCancelled": null + } + }, + { + "name": "독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)", + "ok": true, + "detail": { + "id": "msv_01a1784af809f14b89bc606f73", + "kind": "standalone", + "mailServicesAllocated": 1, + "sitesAllocated": 0 + } + } + ], + "evidence": { + "r": { + "id": "msv_01a1784af809f14b89bc606f73", + "kind": "standalone", + "mailServicesAllocated": 1, + "sitesAllocated": 0 + }, + "listCount": 2, + "noBackupNote": "메일 원문은 고객용 백업·복원에 포함되지 않으며, 메일 백업 기능을 제공하지 않습니다." + } + }, + { + "no": 13, + "title": "백업 옵션 구매와 첫 백업 확인", + "verdict": "partial", + "note": "옵션 구매·활성화까지 확인. 실제 백업 생성은 배포된 사이트 파일이 있어야 함(흐름 6 결함에 막힘)", + "blockedBy": 6, + "steps": [ + { + "name": "백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리", + "ok": true, + "detail": { + "granted": 7, + "orderStatus": "approved" + } + }, + { + "name": "백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서)", + "ok": true, + "detail": { + "eligible": true, + "reasons": [], + "deferred": [], + "policy": null, + "status": 200 + } + }, + { + "name": "옵션 활성화(확인 필수) → 첫 백업 예정 시각", + "ok": true, + "detail": { + "status": "active", + "firstDue": "2026-10-27T12:59:01.665Z", + "nextRun": "2026-10-26T12:59:01.665Z" + } + }, + { + "name": "지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증", + "ok": true, + "detail": { + "timedOut": true, + "optionStatus": "active", + "lastJob": null, + "siteServer": null, + "backup": null, + "job": null + } + } + ], + "evidence": { + "el": { + "eligible": true, + "reasons": [], + "deferred": [], + "policy": null, + "status": 200 + }, + "opt": { + "status": "active", + "firstDue": "2026-10-27T12:59:01.665Z", + "nextRun": "2026-10-26T12:59:01.665Z" + }, + "run1": { + "timedOut": true, + "optionStatus": "active", + "lastJob": null, + "siteServer": null, + "backup": null, + "job": null + } + } + }, + { + "no": 14, + "title": "백업 복원과 외부 사이트 확인", + "verdict": "partial", + "note": "완료된 백업이 없어 복원을 요청할 수 없음", + "blockedBy": 13, + "steps": [], + "evidence": {} + }, + { + "no": 15, + "title": "관리형 사이트 서버 이전과 외부 확인", + "verdict": "partial", + "note": "이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요", + "blockedBy": 6, + "steps": [ + { + "name": "이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원)", + "ok": true, + "detail": { + "auto": { + "ok": false, + "reasons": [ + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다" + ] + }, + "boundaries": { + "lastCancel": "새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다", + "revertHours": 72, + "cost": 10000, + "platformCost": 0 + } + } + }, + { + "name": "고객 요청(유료·승인 대기) 또는 조건 미충족 사유", + "ok": true, + "detail": { + "status": 200, + "relocation": { + "status": "awaiting_approval", + "kind": "customer_paid", + "cost": 10000 + } + } + }, + { + "name": "전환 전 고객 취소 가능(요청 취소 → 소유권 이전 등 다른 작업 차단 해제)", + "ok": true, + "detail": { + "status": 200, + "rstatus": "cancelled" + } + } + ], + "evidence": { + "b": { + "auto": { + "ok": false, + "reasons": [ + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다" + ] + }, + "boundaries": { + "lastCancel": "새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다", + "revertHours": 72, + "cost": 10000, + "platformCost": 0 + } + }, + "req": { + "status": 200, + "relocation": { + "status": "awaiting_approval", + "kind": "customer_paid", + "cost": 10000 + } + } + } + }, + { + "no": 16, + "title": "외부 배포 사이트 생성과 배포", + "verdict": "failed", + "note": "동의(버전)·토큰 발급 거부/허용 규칙까지 확인했으나 토큰 발급 API 가 DB 제약으로 500 — 결함 항목 참조", + "blockedBy": null, + "steps": [ + { + "name": "외부 배포 사이트 생성(초안) + 저장본", + "ok": true, + "detail": { + "id": "s_01a1784b5d03e229abe92a46a1", + "group_id": "g_01a17849a2c2dec27ee9f14777", + "name": "E2E 외부 사이트", + "purpose": "e2e", + "webmaker_project_id": "00000000000000000000000000000004", + "template": null, + "lang": "ko", + "timezone": "Asia/Seoul", + "lifecycle": "draft", + "deploy_mode": "external", + "primary_domain": null, + "site_count_state": "none", + "site_count_reservation_id": null, + "server_id": null, + "external_agent_id": null, + "current_version": null, + "current_deployment_id": null, + "deploy_verify": null, + "health": "unknown", + "health_checked_at": null, + "maintenance": 0, + "search_visible": 1, + "description": null, + "settings_json": null, + "paused_at": null, + "cancel_at": null, + "retention_until": null, + "delete_after": null, + "deleted_at": null, + "created_by": "u_01a17849a21df78a8debb70c18", + "created_at": "2026-10-26T12:59:27.363Z", + "updated_at": "2026-10-26T12:59:27.368Z", + "runtime": "static", + "consent_version": null, + "pending_cleanup_json": null, + "return_due_at": null, + "last_deploy_at": null, + "last_external_check_json": null, + "next_job_json": null, + "mail_choice": null, + "next_notice_at": null, + "delete_hold_reason": null, + "public_stopped_at": null, + "prev_lifecycle": null, + "settings": {}, + "pendingCleanup": null, + "lastExternalCheck": null, + "nextJob": null + } + }, + { + "name": "배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전", + "ok": true, + "detail": { + "notProvided": "[redacted]", + "consentVersion": "external-deploy-2026-10-0.1.1", + "agreed": false + } + }, + { + "name": "동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전)", + "ok": true, + "detail": { + "before": "consent_required", + "consent": 200 + } + }, + { + "name": "외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)", + "ok": false, + "detail": "500 {\"ok\":false,\"error\":\"ERR_SQLITE_ERROR\",\"message\":\"일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요\"} | portal: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ar" + }, + { + "name": "[HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키)", + "ok": true, + "detail": { + "token": "[redacted]", + "expiresAt": "2026-10-26T13:09:27.408Z" + } + }, + { + "name": "[HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected", + "ok": true, + "detail": { + "agentId": "[redacted]", + "status": "connected", + "siteId": "s_01a1784b5d03e229abe92a46a1" + } + }, + { + "name": "[HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성", + "ok": true, + "detail": { + "status": "done", + "error": null, + "errorCode": null, + "result": { + "version": "20261026125937-v1", + "leftovers": [] + }, + "layout": true + } + }, + { + "name": "[HAL 직접] 재배포(v2) → 이전 버전 기록", + "ok": true, + "detail": { + "status": "done", + "error": null, + "errorCode": null, + "result": { + "version": "20261026125950-v2", + "leftovers": [] + } + } + }, + { + "name": "[HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고", + "ok": true, + "detail": { + "status": "failed", + "error": "내부 확인 실패(404) — 이전 버전을 유지합니다", + "errorCode": "internal_check_failed", + "result": null + } + }, + { + "name": "[HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부", + "ok": true, + "detail": {} + }, + { + "name": "[HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거", + "ok": true, + "detail": { + "layoutAfter": false + } + } + ], + "evidence": { + "mode": { + "notProvided": "[redacted]", + "consentVersion": "external-deploy-2026-10-0.1.1", + "agreed": false + } + } + }, + { + "no": 17, + "title": "사용량 경고와 한도 동작 확인", + "verdict": "verified_local", + "note": "메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집)", + "blockedBy": null, + "steps": [ + { + "name": "가짜 AriMail 사용량 95%/월간 100% → 수집(refresh) → 구간·제한 사유·다음 KST 초기화·마지막 계측 표시", + "ok": true, + "detail": { + "storageLevel": 95, + "monthlyLevel": 100, + "nextReset": "2026-10-31T15:00:00.000Z", + "measuredAt": true, + "limit100": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", + "countingRule": "per_final_recipient", + "evaluate": { + "status": 200, + "notified": 2, + "limited": 1 + } + } + }, + { + "name": "알림 센터: 95%·100% 경고가 사건으로 묶여 표시(필수 알림)", + "ok": true, + "detail": { + "count": 3, + "titles": [ + "aw-e2e.localhost 메일 저장 공간 96% — 곧 제한됩니다(최종 경고)", + "aw-e2e.localhost 월간 메일 통수(송신+수신) 100% — 제한 적용", + "메일함 kim@aw-e2e.localhost 저장 공간 95% 경고" + ], + "mandatory": true + } + }, + { + "name": "사용량 화면 제한 플래그(/api/usage/limits)·그룹 요약", + "ok": true, + "detail": { + "limits": [ + "mail_msgs:100", + "mail_storage:95" + ], + "warnLevels": [ + 70, + 80, + 90, + 95 + ] + } + } + ], + "evidence": { + "warn": { + "storageLevel": 95, + "monthlyLevel": 100, + "nextReset": "2026-10-31T15:00:00.000Z", + "measuredAt": true, + "limit100": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", + "countingRule": "per_final_recipient", + "evaluate": { + "status": 200, + "notified": 2, + "limited": 1 + } + }, + "notif": { + "count": 3, + "titles": [ + "aw-e2e.localhost 메일 저장 공간 96% — 곧 제한됩니다(최종 경고)", + "aw-e2e.localhost 월간 메일 통수(송신+수신) 100% — 제한 적용", + "메일함 kim@aw-e2e.localhost 저장 공간 95% 경고" + ], + "mandatory": true + }, + "lim": { + "limits": [ + "mail_msgs:100", + "mail_storage:95" + ], + "warnLevels": [ + 70, + 80, + 90, + 95 + ] + } + } + }, + { + "no": 18, + "title": "상품 축소 보류와 정리 후 적용", + "verdict": "verified_local", + "note": "축소 미리보기→보류→정리(배정 축소·독립 메일 해지)→재검증 적합→축소 주문·승인→권리 교체", + "blockedBy": null, + "steps": [ + { + "name": "배정 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대) + 마지막 단위 동시 요청", + "ok": true, + "detail": { + "purchased": 45, + "mine": 6, + "others": 2, + "unallocated": 37, + "race": [ + 409, + 409 + ], + "mineAfter": 6 + } + }, + { + "name": "축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시", + "ok": true, + "detail": { + "shortages": [ + "mailboxes:1" + ] + } + }, + { + "name": "축소 요청 → 보류(사용 중 권리 즉시 회수 없음)", + "ok": true, + "detail": { + "fits": false, + "hold": "holding", + "sitesStillPurchased": 3 + } + }, + { + "name": "정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성", + "ok": true, + "detail": { + "allocate": 200, + "fits": true, + "shortages": [], + "order": "awaiting_payment" + } + }, + { + "name": "축소 주문 입금 확인·승인 → 권리 교체(베이직)", + "ok": true, + "detail": { + "approve": 200, + "sites": 1, + "mailboxes": 5, + "product": "pv_01a17849a126e838aa8bb17065" + } + } + ], + "evidence": { + "alloc": { + "purchased": 45, + "mine": 6, + "others": 2, + "unallocated": 37, + "race": [ + 409, + 409 + ], + "mineAfter": 6 + }, + "pv": { + "shortages": [ + "mailboxes:1" + ] + }, + "hold": { + "fits": false, + "hold": "holding", + "sitesStillPurchased": 3 + }, + "clean": { + "allocate": 200, + "fits": true, + "shortages": [], + "order": "awaiting_payment" + }, + "applied": { + "approve": 200, + "sites": 1, + "mailboxes": 5, + "product": "pv_01a17849a126e838aa8bb17065" + } + } + }, + { + "no": 19, + "title": "사이트 수 반환과 대표 메일의 독립 전환", + "verdict": "partial", + "note": "반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인)", + "blockedBy": 6, + "steps": [ + { + "name": "§14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션)", + "ok": true, + "detail": { + "ok": false, + "blocking": [ + "domains_unrouted", + "mail_choice", + "mail_converted" + ], + "steps": 8 + } + }, + { + "name": "메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙", + "ok": true, + "detail": { + "status": 409, + "error": "quota_insufficient", + "detail": [ + "resource", + "needed", + "available", + "link" + ] + } + } + ], + "evidence": { + "ck": { + "ok": false, + "blocking": [ + "domains_unrouted", + "mail_choice", + "mail_converted" + ], + "steps": 8 + }, + "keep": { + "status": 409, + "error": "quota_insufficient", + "detail": [ + "resource", + "needed", + "available", + "link" + ] + } + } + }, + { + "no": 20, + "title": "사이트 소유권 이전", + "verdict": "verified_local", + "note": "미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사", + "blockedBy": null, + "steps": [ + { + "name": "이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목)", + "ok": true, + "detail": { + "sites": 1, + "domains": 1, + "siteMail": 1, + "needed": { + "backup": 1, + "web_disk": 1073741824, + "mail_msgs": 500, + "mail_storage": 2147483648, + "mailboxes": 6 + }, + "excluded": 3 + } + }, + { + "name": "요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림", + "ok": true, + "detail": { + "id": "tr_01a1784c7a9e2007160a39f536", + "status": "requested", + "expiresAt": "2026-11-02T13:00:40.478Z", + "bobNotified": true + } + }, + { + "name": "(정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개)", + "ok": true, + "detail": { + "endBackup": 200, + "alloc": 200 + } + }, + { + "name": "받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내)", + "ok": true, + "detail": { + "status": 200, + "tstatus": "reviewing", + "hold": null + } + }, + { + "name": "운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각", + "ok": true, + "detail": { + "bad": "final_confirmation_mismatch", + "ok": 200, + "status": "completed", + "cutover": "2026-10-26T13:00:40.527Z" + } + }, + { + "name": "완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사", + "ok": true, + "detail": { + "aliceSees": 404, + "bobSees": 200, + "bobUsageSites": 0, + "audit": true + } + } + ], + "evidence": { + "pv": { + "sites": 1, + "domains": 1, + "siteMail": 1, + "needed": { + "backup": 1, + "web_disk": 1073741824, + "mail_msgs": 500, + "mail_storage": 2147483648, + "mailboxes": 6 + }, + "excluded": 3 + }, + "rq": { + "id": "tr_01a1784c7a9e2007160a39f536", + "status": "requested", + "expiresAt": "2026-11-02T13:00:40.478Z", + "bobNotified": true + }, + "acc": { + "status": 200, + "tstatus": "reviewing", + "hold": null + }, + "ap": { + "bad": "final_confirmation_mismatch", + "ok": 200, + "status": "completed", + "cutover": "2026-10-26T13:00:40.527Z" + }, + "after": { + "aliceSees": 404, + "bobSees": 200, + "bobUsageSites": 0, + "audit": true + } + } + }, + { + "no": 21, + "title": "중지, 해지, 보존 예정일 표시", + "verdict": "partial", + "note": "생명주기 화면·중지/해지 영향 안내·보존 정책 표시 확인. 운영 중 사이트의 실제 중지·해지는 배포 성공(흐름 6) 필요", + "blockedBy": 6, + "steps": [ + { + "name": "§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림", + "ok": true, + "detail": { + "state": "제작 중", + "siteCount": false, + "publicAccess": "중지됨", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + } + }, + { + "name": "일시 중지(확인 필수) 또는 현재 상태에서 거부 사유", + "ok": true, + "detail": { + "noConfirm": "confirmation_required", + "status": 409, + "error": "lifecycle_transition" + } + }, + { + "name": "메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공)", + "ok": true, + "detail": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } + } + ], + "evidence": { + "v": { + "state": "제작 중", + "siteCount": false, + "publicAccess": "중지됨", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + }, + "pause": { + "noConfirm": "confirmation_required", + "status": 409, + "error": "lifecycle_transition" + }, + "mailLc": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } + } + }, + { + "no": 22, + "title": "HAL 우선 배포 후 HAL을 통한 전체 모듈 배포", + "verdict": "failed", + "note": "HAL 우선 순서(hal_first 거부)·릴리스 승인·부트스트랩 잠김은 확인. 모듈 배포 작업은 완료되지 않음(진단 증거 참조)", + "blockedBy": null, + "steps": [ + { + "name": "릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서", + "ok": true, + "detail": { + "releases": [ + "0.1.2:approved", + "0.1.1:approved" + ], + "running": { + "complete": true, + "firstInstall": false, + "releaseVersion": null, + "mainCommit": null, + "artifactSha256": null, + "halRunningVersion": "0.1.1", + "firstAgent": "[redacted]", + "manualInstallLocked": true + }, + "order": [ + "hal", + "agent", + "portal", + "webmaker", + "arimail", + "backup", + "observability" + ] + } + }, + { + "name": "HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부", + "ok": true, + "detail": { + "status": "failed", + "error": "hal_first" + } + }, + { + "name": "승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시", + "ok": true, + "detail": { + "manualInstallLocked": true, + "complete": true + } + }, + { + "name": "HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인)", + "ok": true, + "detail": { + "status": "queued", + "steps": [ + "precheck:pending", + "fanout:pending", + "collect:pending" + ], + "error": null, + "children": [], + "agentJobs": [], + "timedOut": true + } + } + ], + "evidence": { + "rel": { + "releases": [ + "0.1.2:approved", + "0.1.1:approved" + ], + "running": { + "complete": true, + "firstInstall": false, + "releaseVersion": null, + "mainCommit": null, + "artifactSha256": null, + "halRunningVersion": "0.1.1", + "firstAgent": "[redacted]", + "manualInstallLocked": true + }, + "order": [ + "hal", + "agent", + "portal", + "webmaker", + "arimail", + "backup", + "observability" + ] + }, + "hf": { + "status": "failed", + "error": "hal_first" + }, + "na": { + "manualInstallLocked": true, + "complete": true + }, + "dep": { + "status": "queued", + "steps": [ + "precheck:pending", + "fanout:pending", + "collect:pending" + ], + "error": null, + "children": [], + "agentJobs": [], + "timedOut": true + }, + "diag": { + "stuckNow": [ + { + "id": "j_01a1784d7f02143ed47d1fcaf9", + "kind": "domain.probe", + "status": "queued", + "attempt": 0, + "step": "dispatch", + "detail": "", + "updatedAt": "2026-10-26T13:01:47.138Z" + }, + { + "id": "j_01a1784d6b6a20949d03d2dee8", + "kind": "domain.probe", + "status": "queued", + "attempt": 0, + "step": "dispatch", + "detail": "", + "updatedAt": "2026-10-26T13:01:42.122Z" + }, + { + "id": "j_01a1784d671c8f951049094a53", + "kind": "module.deploy", + "status": "queued", + "attempt": 0, + "step": "precheck", + "detail": "", + "updatedAt": "2026-10-26T13:01:41.020Z" + }, + { + "id": "j_01a1784d57cf28f7528a1a8ccb", + "kind": "domain.probe", + "status": "queued", + "attempt": 0, + "step": "dispatch", + "detail": "", + "updatedAt": "2026-10-26T13:01:37.103Z" + }, + { + "id": "j_01a1784d44338bf61b6691d5a8", + "kind": "domain.probe", + "status": "running", + "attempt": 1, + "step": "await", + "detail": "", + "updatedAt": "2026-10-26T13:01:46.885Z" + } + ], + "halLog": [ + "2026-10-26T12:58:56.345Z [jobs] site.deploy j_01a1784ac7d0815275cc3e88aa 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/lIw5d3l2igVGOIlJa1BCaKwXYo7r2bJJ", + "2026-10-26T12:59:11.375Z [jobs] backup.create j_01a1784afb5dd2de92b7a63b4f 실패: 대상 서버 또는 Agent 가 필요합니다", + "2026-10-26T13:00:04.476Z [jobs] site.deploy j_01a1784bbc086436dc87f4b997 실패: 내부 확인 실패(404) — 이전 버전을 유지합니다", + "2026-10-26T13:00:18.498Z [jobs] site.deploy j_01a1784befea6c15eb6d7782eb 실패: 이 외부 Agent 는 다른 사이트에 귀속되어 있습니다", + "2026-10-26T13:00:31.517Z [jobs] backup.create j_01a1784c1721827ff3a7fd81e1 실패: 외부 Agent 는 backup.create 작업을 받을 수 없습니다", + "2026-10-26T13:01:40.630Z [jobs] module.deploy j_01a1784d2b6df1c41a7815b555 실패: HAL 이 아직 0.1.2 으로 실행되고 있지 않습니다(현재 0.1.1). HAL 을 먼저 배포하세요" + ], + "ctlAgentLog": [ + "2026-10-26T12:57:32.329Z [agent] Ariweb Node Agent v0.1.1 ag_01a178499afa793eb4b22d9b01 (managed) → http://127.0.0.1:35361" + ] + } + } + }, + { + "no": 23, + "title": "실패 작업의 재시도와 되돌리기", + "verdict": "partial", + "note": "실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요", + "blockedBy": 6, + "steps": [ + { + "name": "배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호", + "ok": true, + "detail": { + "status": "cancelled", + "failedStep": null, + "canRetry": false, + "jobId": "j_01a17849a4096e33f476b17d1b" + } + }, + { + "name": "재시도 요청(POST /api/sites/:id/jobs/:jobId/retry)", + "ok": true, + "detail": { + "status": 500, + "error": "job_not_retryable" + } + }, + { + "name": "되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부", + "ok": true, + "detail": { + "status": 409, + "error": "no_previous", + "message": "되돌릴 이전 버전이 없습니다" + } + } + ], + "evidence": { + "dep": { + "status": "cancelled", + "failedStep": null, + "canRetry": false, + "jobId": "j_01a17849a4096e33f476b17d1b" + }, + "rt": { + "status": 500, + "error": "job_not_retryable" + }, + "rb": { + "status": 409, + "error": "no_previous", + "message": "되돌릴 이전 버전이 없습니다" + } + } + }, + { + "no": 24, + "title": "운영자 승인, 감사 기록, 고객 알림의 일치", + "verdict": "verified_local", + "note": "승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준)", + "blockedBy": null, + "steps": [ + { + "name": "고객 알림 센터에 승인 알림", + "ok": true, + "detail": { + "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", + "category": "billing", + "link": "/orders/o_01a17849a33601ca26016fa94a" + } + }, + { + "name": "감사 기록: order.approve 는 삭제 불가(immutable)", + "ok": true, + "detail": { + "action": "order.approve", + "immutable": true + } + }, + { + "name": "운영자 화면과 고객 주문 화면의 상태 일치", + "ok": true, + "detail": { + "status": "approved", + "approvedAt": "2026-10-26T12:57:34.327Z" + } + } + ], + "evidence": { + "n": { + "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", + "category": "billing", + "link": "/orders/o_01a17849a33601ca26016fa94a" + }, + "a": { + "action": "order.approve", + "immutable": true + }, + "same": { + "status": "approved", + "approvedAt": "2026-10-26T12:57:34.327Z" + } + } + }, + { + "no": 25, + "title": "HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련", + "verdict": "partial", + "note": "DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요", + "blockedBy": null, + "steps": [ + { + "name": "설정 전 준비 상태(미설정 항목)", + "ok": true, + "detail": { + "ready": false, + "missingConfig": [ + "rto_minutes", + "rpo_minutes", + "snapshot_interval_minutes", + "retention_count" + ], + "lastDrill": null, + "lastSnapshot": null, + "config": { + "rto_minutes": null, + "rpo_minutes": null, + "snapshot_interval_minutes": null, + "retention_count": null, + "external_store": null + }, + "label": "정식 운영 준비 전(복구 훈련 또는 설정 필요)" + } + }, + { + "name": "RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정", + "ok": true, + "detail": { + "id": 1, + "rto_minutes": 60, + "rpo_minutes": 30, + "snapshot_interval_minutes": 60, + "retention_count": 3, + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026125729/dr-external", + "updated_at": "2026-10-26T13:02:07.823Z", + "updated_by": "op_01a178499a27f1ba88344e1145" + } + }, + { + "name": "복구 사본 생성·검증·외부 보관(vault.key 제외)", + "ok": true, + "detail": { + "verify": "ok", + "external": true, + "sha": true + } + }, + { + "name": "복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료", + "ok": true, + "detail": { + "early": "restore_not_approved", + "noStop": "old_control_not_confirmed", + "command": true, + "incomplete": "checks_incomplete", + "final": "completed_with_findings" + } + }, + { + "name": "복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료", + "ok": true, + "detail": { + "first": "failed", + "second": "success", + "ready": true, + "measured": {} + } + } + ], + "evidence": { + "r0": { + "ready": false, + "missingConfig": [ + "rto_minutes", + "rpo_minutes", + "snapshot_interval_minutes", + "retention_count" + ], + "lastDrill": null, + "lastSnapshot": null, + "config": { + "rto_minutes": null, + "rpo_minutes": null, + "snapshot_interval_minutes": null, + "retention_count": null, + "external_store": null + }, + "label": "정식 운영 준비 전(복구 훈련 또는 설정 필요)" + }, + "cfg": { + "id": 1, + "rto_minutes": 60, + "rpo_minutes": 30, + "snapshot_interval_minutes": 60, + "retention_count": 3, + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026125729/dr-external", + "updated_at": "2026-10-26T13:02:07.823Z", + "updated_by": "op_01a178499a27f1ba88344e1145" + }, + "snap": { + "verify": "ok", + "external": true, + "sha": true + }, + "rs": { + "early": "restore_not_approved", + "noStop": "old_control_not_confirmed", + "command": true, + "incomplete": "checks_incomplete", + "final": "completed_with_findings" + }, + "drill": { + "first": "failed", + "second": "success", + "ready": true, + "measured": {} + } + } + }, + { + "no": 26, + "title": "새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지", + "verdict": "verified_local", + "note": "포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요", + "blockedBy": null, + "steps": [ + { + "name": "재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록", + "ok": true, + "detail": { + "mailJob": { + "id": "j_01a1784ae61b14370f923139a1", + "status": "awaiting_external" + }, + "halJobs": 50 + } + }, + { + "name": "포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지", + "ok": true, + "detail": { + "sessionKept": "[redacted]", + "mailJob": { + "id": "j_01a1784ae61b14370f923139a1", + "status": "awaiting_external" + }, + "same": true + } + }, + { + "name": "HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지", + "ok": true, + "detail": { + "opsSessionKept": "[redacted]", + "halJobs": 50, + "sameCount": true + } + } + ], + "evidence": { + "before": { + "mailJob": { + "id": "j_01a1784ae61b14370f923139a1", + "status": "awaiting_external" + }, + "halJobs": 50 + }, + "pr": { + "sessionKept": "[redacted]", + "mailJob": { + "id": "j_01a1784ae61b14370f923139a1", + "status": "awaiting_external" + }, + "same": true + }, + "hr": { + "opsSessionKept": "[redacted]", + "halJobs": 50, + "sameCount": true + } + } + }, + { + "no": 27, + "title": "마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공", + "verdict": "verified_local", + "note": "마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3)", + "blockedBy": null, + "steps": [ + { + "name": "밥 개인 그룹: 베이직(사이트 1) 주문·승인", + "ok": true, + "detail": { + "granted": 6 + } + }, + { + "name": "사이트 2개 생성·저장본", + "ok": true, + "detail": [ + { + "id": "s_01a1784ac5a389af03b57b32a3", + "group_id": "g_01a17849a2ee454ab48b65c13d", + "name": "동시 A", + "purpose": "e2e", + "webmaker_project_id": "00000000000000000000000000000002", + "template": null, + "lang": "ko", + "timezone": "Asia/Seoul", + "lifecycle": "draft", + "deploy_mode": "managed", + "primary_domain": null, + "site_count_state": "none", + "site_count_reservation_id": null, + "server_id": null, + "external_agent_id": null, + "current_version": null, + "current_deployment_id": null, + "deploy_verify": null, + "health": "unknown", + "health_checked_at": null, + "maintenance": 0, + "search_visible": 1, + "description": null, + "settings_json": null, + "paused_at": null, + "cancel_at": null, + "retention_until": null, + "delete_after": null, + "deleted_at": null, + "created_by": "u_01a17849a2eef6c9267cb0d204", + "created_at": "2026-10-26T12:58:48.611Z", + "updated_at": "2026-10-26T12:58:48.619Z", + "runtime": "static", + "consent_version": null, + "pending_cleanup_json": null, + "return_due_at": null, + "last_deploy_at": null, + "last_external_check_json": null, + "next_job_json": null, + "mail_choice": null, + "next_notice_at": null, + "delete_hold_reason": null, + "public_stopped_at": null, + "prev_lifecycle": null, + "settings": {}, + "pendingCleanup": null, + "lastExternalCheck": null, + "nextJob": null + }, + { + "id": "s_01a1784ac5a7fa23018c39de03", + "group_id": "g_01a17849a2ee454ab48b65c13d", + "name": "동시 B", + "purpose": "e2e", + "webmaker_project_id": "00000000000000000000000000000003", + "template": null, + "lang": "ko", + "timezone": "Asia/Seoul", + "lifecycle": "draft", + "deploy_mode": "managed", + "primary_domain": null, + "site_count_state": "none", + "site_count_reservation_id": null, + "server_id": null, + "external_agent_id": null, + "current_version": null, + "current_deployment_id": null, + "deploy_verify": null, + "health": "unknown", + "health_checked_at": null, + "maintenance": 0, + "search_visible": 1, + "description": null, + "settings_json": null, + "paused_at": null, + "cancel_at": null, + "retention_until": null, + "delete_after": null, + "deleted_at": null, + "created_by": "u_01a17849a2eef6c9267cb0d204", + "created_at": "2026-10-26T12:58:48.615Z", + "updated_at": "2026-10-26T12:58:48.622Z", + "runtime": "static", + "consent_version": null, + "pending_cleanup_json": null, + "return_due_at": null, + "last_deploy_at": null, + "last_external_check_json": null, + "next_job_json": null, + "mail_choice": null, + "next_notice_at": null, + "delete_hold_reason": null, + "public_stopped_at": null, + "prev_lifecycle": null, + "settings": {}, + "pendingCleanup": null, + "lastExternalCheck": null, + "nextJob": null + } + ] + }, + { + "name": "두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족)", + "ok": true, + "detail": { + "ok": 1, + "blocked": "precheck_failed", + "shortage": "block" + } + }, + { + "name": "(정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환", + "ok": true, + "detail": { + "cancel": 200, + "cleanup": 200, + "unallocated": 0 + } + } + ], + "evidence": { + "race": { + "ok": 1, + "blocked": "precheck_failed", + "shortage": "block" + } + } + }, + { + "no": 28, + "title": "외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리", + "verdict": "partial", + "note": "HAL·외부 Agent 경로(등록·연결·배포·재배포·실패 시 이전 버전 유지와 남은 파일·다른 사이트/허용되지 않는 종류 거부·제거)는 확인. 포털 화면 경로는 토큰 발급 결함으로 막힘", + "blockedBy": null, + "steps": [ + { + "name": "외부 배포 사이트 생성(초안) + 저장본", + "ok": true, + "detail": { + "id": "s_01a1784b5d03e229abe92a46a1", + "group_id": "g_01a17849a2c2dec27ee9f14777", + "name": "E2E 외부 사이트", + "purpose": "e2e", + "webmaker_project_id": "00000000000000000000000000000004", + "template": null, + "lang": "ko", + "timezone": "Asia/Seoul", + "lifecycle": "draft", + "deploy_mode": "external", + "primary_domain": null, + "site_count_state": "none", + "site_count_reservation_id": null, + "server_id": null, + "external_agent_id": null, + "current_version": null, + "current_deployment_id": null, + "deploy_verify": null, + "health": "unknown", + "health_checked_at": null, + "maintenance": 0, + "search_visible": 1, + "description": null, + "settings_json": null, + "paused_at": null, + "cancel_at": null, + "retention_until": null, + "delete_after": null, + "deleted_at": null, + "created_by": "u_01a17849a21df78a8debb70c18", + "created_at": "2026-10-26T12:59:27.363Z", + "updated_at": "2026-10-26T12:59:27.368Z", + "runtime": "static", + "consent_version": null, + "pending_cleanup_json": null, + "return_due_at": null, + "last_deploy_at": null, + "last_external_check_json": null, + "next_job_json": null, + "mail_choice": null, + "next_notice_at": null, + "delete_hold_reason": null, + "public_stopped_at": null, + "prev_lifecycle": null, + "settings": {}, + "pendingCleanup": null, + "lastExternalCheck": null, + "nextJob": null + } + }, + { + "name": "배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전", + "ok": true, + "detail": { + "notProvided": "[redacted]", + "consentVersion": "external-deploy-2026-10-0.1.1", + "agreed": false + } + }, + { + "name": "동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전)", + "ok": true, + "detail": { + "before": "consent_required", + "consent": 200 + } + }, + { + "name": "외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)", + "ok": false, + "detail": "500 {\"ok\":false,\"error\":\"ERR_SQLITE_ERROR\",\"message\":\"일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요\"} | portal: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ar" + }, + { + "name": "[HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키)", + "ok": true, + "detail": { + "token": "[redacted]", + "expiresAt": "2026-10-26T13:09:27.408Z" + } + }, + { + "name": "[HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected", + "ok": true, + "detail": { + "agentId": "[redacted]", + "status": "connected", + "siteId": "s_01a1784b5d03e229abe92a46a1" + } + }, + { + "name": "[HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성", + "ok": true, + "detail": { + "status": "done", + "error": null, + "errorCode": null, + "result": { + "version": "20261026125937-v1", + "leftovers": [] + }, + "layout": true + } + }, + { + "name": "[HAL 직접] 재배포(v2) → 이전 버전 기록", + "ok": true, + "detail": { + "status": "done", + "error": null, + "errorCode": null, + "result": { + "version": "20261026125950-v2", + "leftovers": [] + } + } + }, + { + "name": "[HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고", + "ok": true, + "detail": { + "status": "failed", + "error": "내부 확인 실패(404) — 이전 버전을 유지합니다", + "errorCode": "internal_check_failed", + "result": null + } + }, + { + "name": "[HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부", + "ok": true, + "detail": {} + }, + { + "name": "[HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거", + "ok": true, + "detail": { + "layoutAfter": false + } + } + ], + "evidence": { + "halTok": { + "expiresAt": "2026-10-26T13:09:27.408Z" + }, + "d1": { + "status": "done", + "error": null, + "errorCode": null, + "result": { + "version": "20261026125937-v1", + "leftovers": [] + }, + "layout": true + }, + "d2": { + "status": "done", + "error": null, + "errorCode": null, + "result": { + "version": "20261026125950-v2", + "leftovers": [] + } + }, + "d3": { + "status": "failed", + "error": "내부 확인 실패(404) — 이전 버전을 유지합니다", + "errorCode": "internal_check_failed", + "result": null + }, + "d4": {}, + "d5": { + "layoutAfter": false + } + } + }, + { + "no": 29, + "title": "HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시", + "verdict": "verified_local", + "note": "HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀", + "blockedBy": null, + "steps": [ + { + "name": "HAL 중단 중: 기존 조회(사이트 목록·개요)는 제공, 배포 전 확인/배포는 정보 확인 지연·차단", + "ok": true, + "detail": { + "list": 200, + "overview": 200, + "precheck": { + "status": 200, + "blocks": [ + "target:HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정" + ] + }, + "deploy": { + "status": 409, + "error": "precheck_failed", + "message": "배포 전 확인에서 차단된 항목이 있습니다" + } + } + }, + { + "name": "대시보드/알림에 중앙 상태 확인 지연 표시(가능하면)", + "ok": true, + "detail": { + "status": 200, + "halState": null + } + }, + { + "name": "HAL 재기동(같은 호스트의 죽은 인스턴스 임대 이어받기) → 서버·Agent 상태 복귀", + "ok": true, + "detail": { + "takeoverNeeded": false, + "bootstrap": true, + "readyServers": 4 + } + } + ], + "evidence": { + "during": { + "list": 200, + "overview": 200, + "precheck": { + "status": 200, + "blocks": [ + "target:HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정" + ] + }, + "deploy": { + "status": 409, + "error": "precheck_failed", + "message": "배포 전 확인에서 차단된 항목이 있습니다" + } + }, + "dash": { + "status": 200, + "halState": null + }, + "back": { + "takeoverNeeded": false, + "bootstrap": true, + "readyServers": 4 + } + } + }, + { + "no": 30, + "title": "모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정", + "verdict": "failed", + "note": "대상별 자식 작업 생성·되돌리기 3단계 확인 거부는 확인. 일부 실패 요약·건강 확인·drift 조정은 작업 엔진 교착(흐름 22 결함)으로 완료되지 않음", + "blockedBy": 22, + "steps": [ + { + "name": "agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed)", + "ok": true, + "detail": { + "status": "queued", + "errorCode": null, + "overall": "none", + "children": [], + "timedOut": true + } + }, + { + "name": "모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업", + "ok": true, + "detail": { + "health": "timeout", + "drift": 0, + "reconcile": 200, + "overviewComponents": [ + "HAL:ok", + "portal:unknown", + "webmaker:unknown", + "arimail:unknown", + "backup:ok", + "observability:ok" + ] + } + }, + { + "name": "되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부", + "ok": true, + "detail": { + "step1": "confirm_impact_required", + "step2": "identifiers_mismatch" + } + } + ], + "evidence": { + "d": { + "status": "queued", + "errorCode": null, + "overall": "none", + "children": [], + "timedOut": true + }, + "h": { + "health": "timeout", + "drift": 0, + "reconcile": 200, + "overviewComponents": [ + "HAL:ok", + "portal:unknown", + "webmaker:unknown", + "arimail:unknown", + "backup:ok", + "observability:ok" + ] + }, + "rb": { + "step1": "confirm_impact_required", + "step2": "identifiers_mismatch" + } + } + }, + { + "no": 31, + "title": "긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구", + "verdict": "partial", + "note": "정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인)", + "blockedBy": null, + "steps": [ + { + "name": "HAL 정상 상태에서 선언 거부(hal_healthy)", + "ok": true, + "detail": "hal_healthy" + }, + { + "name": "critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)", + "ok": true, + "detail": { + "id": "em_01a1784dd0ec3f08f6ddf4281a", + "noCred": "approvals_insufficient", + "wrongStep": "wrong_step", + "badIds": "identifiers_mismatch", + "badText": "final_confirm_mismatch", + "approved": "approved", + "approvals": 3, + "credential": "[redacted]", + "expiresAt": "2026-10-26T13:32:08.135Z" + } + }, + { + "name": "일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료", + "ok": true, + "detail": { + "forbidden": "action_forbidden", + "outOfScope": "out_of_scope", + "badCred": "bad_credential", + "performed": 400, + "revoked": true, + "afterRevoke": "credential_revoked", + "reconcile": 200, + "closed": "closed" + } + } + ], + "evidence": { + "healthy": "hal_healthy", + "sess": { + "id": "em_01a1784dd0ec3f08f6ddf4281a", + "noCred": "approvals_insufficient", + "wrongStep": "wrong_step", + "badIds": "identifiers_mismatch", + "badText": "final_confirm_mismatch", + "approved": "approved", + "approvals": 3, + "expiresAt": "2026-10-26T13:32:08.135Z" + }, + "perf": { + "forbidden": "action_forbidden", + "outOfScope": "out_of_scope", + "badCred": "bad_credential", + "performed": 400, + "revoked": true, + "afterRevoke": "credential_revoked", + "reconcile": 200, + "closed": "closed" + } + } + }, + { + "no": 32, + "title": "Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료", + "verdict": "partial", + "note": "활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인)", + "blockedBy": null, + "steps": [ + { + "name": "Source 옵션 주문(대상 사이트)·승인 → source 권리", + "ok": true, + "detail": { + "granted": 7 + } + }, + { + "name": "Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보", + "ok": true, + "detail": { + "activate": 200, + "status": "active", + "provider": "gitea", + "changes": 1, + "clone": true + } + }, + { + "name": "export(저장본 내려받기)", + "ok": true, + "detail": { + "status": 200, + "type": "application/gzip" + } + }, + { + "name": "옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단", + "ok": true, + "detail": { + "status": "readonly", + "deleteAfter": "2026-11-25T13:00:40.412Z", + "readonlyUntil": "2026-11-25T13:00:40.412Z" + } + }, + { + "name": "삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음", + "ok": true, + "detail": { + "sweep": 200, + "statusAfter": "readonly" + } + } + ], + "evidence": { + "act": { + "activate": 200, + "status": "active", + "provider": "gitea", + "changes": 1, + "clone": true + }, + "ex": { + "status": 200, + "type": "application/gzip" + }, + "un": { + "status": "readonly", + "deleteAfter": "2026-11-25T13:00:40.412Z", + "readonlyUntil": "2026-11-25T13:00:40.412Z" + }, + "sweep": { + "sweep": 200, + "statusAfter": "readonly" + } + } + }, + { + "no": 33, + "title": "메일 서비스의 사이트 대표→독립 전환 후 항목 유지", + "verdict": "partial", + "note": "전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인)", + "blockedBy": null, + "steps": [ + { + "name": "사이트 대표→독립 전환 점검표(주소·메일함·별칭·전달·비밀번호·DNS·옵션·작업)", + "ok": true, + "detail": { + "canConvert": false, + "items": [ + "addresses:ok", + "data:ok", + "aliases:ok", + "forwards:ok", + "passwords:ok", + "dns:x", + "entitlement:x", + "jobs:x" + ] + } + }, + { + "name": "DNS 미확인 상태에서 전환 요청은 차단", + "ok": true, + "detail": { + "status": 409, + "error": "conversion_blocked" + } + } + ], + "evidence": { + "ck": { + "canConvert": false, + "items": [ + "addresses:ok", + "data:ok", + "aliases:ok", + "forwards:ok", + "passwords:ok", + "dns:x", + "entitlement:x", + "jobs:x" + ] + }, + "tr": { + "status": 409, + "error": "conversion_blocked" + } + } + }, + { + "no": 34, + "title": "일시 중지·해지 예약·보존·삭제 단계의 표시 일치", + "verdict": "partial", + "note": "해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인)", + "blockedBy": 6, + "steps": [ + { + "name": "§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림", + "ok": true, + "detail": { + "state": "제작 중", + "siteCount": false, + "publicAccess": "중지됨", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + } + }, + { + "name": "일시 중지(확인 필수) 또는 현재 상태에서 거부 사유", + "ok": true, + "detail": { + "noConfirm": "confirmation_required", + "status": 409, + "error": "lifecycle_transition" + } + }, + { + "name": "메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공)", + "ok": true, + "detail": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } + } + ], + "evidence": { + "v": { + "state": "제작 중", + "siteCount": false, + "publicAccess": "중지됨", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + }, + "mailLc": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } + } + } + ], + "bugs": [ + { + "title": "Agent 가 포털 저장본(bundleUrl, 절대 URL)을 내려받지 못함(HAL 주소를 앞에 덧붙임)", + "file": "agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle)", + "route": "HAL site.deploy → Agent fetch 단계", + "expected": "bundleUrl 이 절대 URL 이면 그대로 내려받는다(서명은 HAL 전용이므로 포털 번들 URL 에는 붙이지 않음)", + "actual": "Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", + "repro": "포털 배포 → HAL site.deploy → Agent: \"Failed to parse URL from http://http:///internal/bundles/…\"", + "flows": [ + 6, + 8, + 9, + 13, + 14, + 15, + 23 + ] + }, + { + "title": "포털 배포 작업이 실패한 HAL 작업을 실패로 보지 않고 5초마다 새 HAL 작업을 무한 재제출(배포가 영원히 \"진행 중\")", + "file": "portal/sites/service.mjs (halJobFor: prev HAL 작업이 failed/cancelled 면 fresh 가 아니어도 새 작업을 만든다 → awaitHal 이 실패를 보지 못함)", + "route": "POST /api/sites/:id/deploy", + "expected": "HAL 작업이 failed 면 포털 작업도 failed(이전 버전 유지·재시도 버튼), 새 HAL 작업은 사용자 재시도(fresh) 때만", + "actual": "portal job queued attempt 9 (배포 서버 작업 진행 중); HAL 에 같은 사이트의 site.deploy 실패 작업이 attempt 수만큼 쌓임", + "repro": "Agent 가 실패하는 배포를 요청하고 /api/sites/:id/jobs/:id 를 지켜보면 attempt 가 5초마다 증가", + "flows": [ + 6, + 23, + 13, + 20 + ] + }, + { + "title": "관리형 배포 종단 흐름 실패(포털→HAL→Agent 저장본 전달)", + "file": "agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle: client.download(p.bundleUrl))", + "route": "site.deploy → Agent fetch 단계", + "expected": "절대 URL(bundleUrl=http:///internal/bundles/)은 HAL base 를 붙이지 않고 그대로 내려받아야 한다", + "actual": "시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: [\"failed\",\"Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni\"])", + "repro": "사이트 생성→저장본→POST /api/sites/:id/deploy→HAL site.deploy→Agent site.deploy fetch 단계에서 \"HAL 내려받기 실패\" 또는 잘못된 URL 오류", + "flows": [ + 6, + 8, + 9, + 13, + 14, + 15, + 23 + ] + }, + { + "title": "취소된 배포 작업의 재시도 요청이 500(서버 오류)으로 응답(작업 상태 오류는 4xx 여야 함)", + "file": "portal/sites/api.mjs (jobs/:jobId/:action → lib/jobs JobError 를 HttpError 로 바꾸지 않음)", + "route": "POST /api/sites/:id/jobs/:jobId/retry", + "expected": "409 job_not_retryable + 안내", + "actual": "500 job_not_retryable", + "repro": "취소된 작업에 retry", + "flows": [ + 23 + ] + }, + { + "title": "외부 Agent 토큰 발급이 DB 제약(external_agents.hal_agent_id NOT NULL)으로 500", + "file": "portal/sites/service.mjs (createExternalAgent) / portal/sites/model.mjs (external_agents.hal_agent_id)", + "route": "POST /api/sites/:id/external", + "expected": "HAL 토큰 발급 응답의 agentId 가 null(등록 전)이어도 토큰·설치 명령을 돌려주고, Agent 등록 뒤 by-site 조회로 hal_agent_id 를 채운다", + "actual": "2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ariweb-suite/lib/db.mjs:52:41) | at Object.createExternalAgent (file:///home/master/work/Ariweb", + "repro": "외부 배포 사이트 → 동의 → POST /api/sites/:id/external → 500 \"NOT NULL constraint failed: external_agents.hal_agent_id\"", + "flows": [ + 16, + 28 + ] + }, + { + "title": "HAL 작업 엔진이 작업을 한 번에 하나씩만 실행(tick 이 execute 를 await)해 느린 작업이 큐 전체를 막고, module.deploy 부모가 자식 완료를 기다리면 자식이 실행될 수 없어 교착", + "file": "lib/jobs.mjs (createJobEngine.start/tick) / hal/jobs.mjs (fanout 이 자식 완료를 폴링)", + "route": "POST /ops/api/modules/:name/deploy", + "expected": "엔진은 running 수가 concurrency 미만이면 실행을 분리(detach)해 여러 작업을 동시에 진행해야 한다(부모가 자식을 기다려도 자식이 실행됨)", + "actual": { + "status": "queued", + "steps": [ + "precheck:pending", + "fanout:pending", + "collect:pending" + ], + "error": null, + "children": [], + "agentJobs": [], + "timedOut": true + }, + "repro": "(1) Agent 가 수십 초 걸리는 domain.probe 가 queued 로 쌓이면 다른 모든 HAL 작업이 기다린다 (2) 큐가 비어도 모듈 배포는 부모 collect:running + 자식 queued 로 영원히 멈춘다(test/hal.test.mjs 는 pump() 로 tick 을 동시에 돌려 통과)", + "flows": [ + 22, + 30 + ] + } + ], + "needsInfrastructure": [ + "도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인은 실제 DNS 와 공인 서버가 필요하다(흐름 7·8·11·14·15)", + "메일 DNS(MX·SPF·DKIM·DMARC) 반영과 외부 송신·수신·회신 왕복은 실 도메인·실 AriMail(SMTP 587/수신함)·외부 검증 주소가 필요하다(흐름 10 활성화 완료·11·33 완료)", + "재해복구의 실제 복원(hal-restore.sh 로 다른 호스트에 HAL 재기동)·실제 운영자 로그인·HTTPS·고객 서비스 외부 확인은 실 서버가 필요하다(흐름 25 기록·훈련 절차만 로컬 확인)" + ] +} \ No newline at end of file diff --git a/docs/verification/local-e2e-2026-10-26.md b/docs/verification/local-e2e-2026-10-26.md new file mode 100644 index 0000000..0adee5b --- /dev/null +++ b/docs/verification/local-e2e-2026-10-26.md @@ -0,0 +1,1186 @@ +# 로컬 종단 간 검증 기록 — 2026-10-26 + +생성: 2026-10-26T13:02:10.786Z · `node --no-warnings test/e2e.test.mjs` (test/e2e/) + +실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 4대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다. + +## 요약 + +| 판정 | 수 | +|---|---| +| 로컬 확인 | 13 | +| 부분 확인 | 15 | +| 로컬 확인 불가 | 2 | +| 실패(결함) | 4 | +| unrecorded | 0 | + +## 흐름별 판정(Release 0.1.1 완료 조건 34개) + +| # | 흐름 | 판정 | 확인 내용 / 비고 | +|---|---|---|---| +| 1 | 신규 고객 가입과 이메일 확인 | 로컬 확인 | 가입→확인 메일(포획)→1회용 토큰 확인→개인 그룹·별칭→중복 가입 안내→기기 목록·세션 종료 | +| 2 | 개인 사용자 그룹 생성과 다른 사용자 초대 | 로컬 확인 | 팀 그룹 생성·초대 메일·1회용 수락·별칭 user-02·역할 거부·그룹 전환 격리 | +| 3 | 상품 주문, 결제 확인, 권리 활성화 | 로컬 확인 | 상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면 | +| 4 | 초안 사이트 생성 | 로컬 확인 | 초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터 | +| 5 | Webmaker에서 페이지 제작 | 로컬 확인 | SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음) | +| 6 | 관리형 배포 | 실패(결함) | 시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: ["failed","Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni"]) | +| 7 | 도메인 연결과 HTTPS 발급 | 부분 확인 | 도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가 | +| 8 | 외부 주소에서 사이트 확인 | 로컬 확인 불가 | 외부 주소 확인은 공인 서버·DNS 필요 (선행 흐름 6 에 막힘) | +| 9 | 사이트 정보와 접속·사용량 통계 확인 | 부분 확인 | 미배포 사이트는 통계 대신 준비 단계 표시(§8.1) 확인. 실제 접속·네트워크 통계는 배포 뒤 수집 (선행 흐름 6 에 막힘) | +| 10 | 사이트 대표 메일과 메일함 생성 | 부분 확인 | 대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요 | +| 11 | 외부 송신, 수신, 회신 확인 | 로컬 확인 불가 | 외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨 | +| 12 | 독립 메일 서비스 생성 | 로컬 확인 | 독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구 | +| 13 | 백업 옵션 구매와 첫 백업 확인 | 부분 확인 | 옵션 구매·활성화까지 확인. 실제 백업 생성은 배포된 사이트 파일이 있어야 함(흐름 6 결함에 막힘) (선행 흐름 6 에 막힘) | +| 14 | 백업 복원과 외부 사이트 확인 | 부분 확인 | 완료된 백업이 없어 복원을 요청할 수 없음 (선행 흐름 13 에 막힘) | +| 15 | 관리형 사이트 서버 이전과 외부 확인 | 부분 확인 | 이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요 (선행 흐름 6 에 막힘) | +| 16 | 외부 배포 사이트 생성과 배포 | 실패(결함) | 동의(버전)·토큰 발급 거부/허용 규칙까지 확인했으나 토큰 발급 API 가 DB 제약으로 500 — 결함 항목 참조 | +| 17 | 사용량 경고와 한도 동작 확인 | 로컬 확인 | 메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집) | +| 18 | 상품 축소 보류와 정리 후 적용 | 로컬 확인 | 축소 미리보기→보류→정리(배정 축소·독립 메일 해지)→재검증 적합→축소 주문·승인→권리 교체 | +| 19 | 사이트 수 반환과 대표 메일의 독립 전환 | 부분 확인 | 반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인) (선행 흐름 6 에 막힘) | +| 20 | 사이트 소유권 이전 | 로컬 확인 | 미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사 | +| 21 | 중지, 해지, 보존 예정일 표시 | 부분 확인 | 생명주기 화면·중지/해지 영향 안내·보존 정책 표시 확인. 운영 중 사이트의 실제 중지·해지는 배포 성공(흐름 6) 필요 (선행 흐름 6 에 막힘) | +| 22 | HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 | 실패(결함) | HAL 우선 순서(hal_first 거부)·릴리스 승인·부트스트랩 잠김은 확인. 모듈 배포 작업은 완료되지 않음(진단 증거 참조) | +| 23 | 실패 작업의 재시도와 되돌리기 | 부분 확인 | 실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요 (선행 흐름 6 에 막힘) | +| 24 | 운영자 승인, 감사 기록, 고객 알림의 일치 | 로컬 확인 | 승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준) | +| 25 | HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 | 부분 확인 | DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요 | +| 26 | 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 | 로컬 확인 | 포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요 | +| 27 | 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 | 로컬 확인 | 마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3) | +| 28 | 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 | 부분 확인 | HAL·외부 Agent 경로(등록·연결·배포·재배포·실패 시 이전 버전 유지와 남은 파일·다른 사이트/허용되지 않는 종류 거부·제거)는 확인. 포털 화면 경로는 토큰 발급 결함으로 막힘 | +| 29 | HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 | 로컬 확인 | HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀 | +| 30 | 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 | 실패(결함) | 대상별 자식 작업 생성·되돌리기 3단계 확인 거부는 확인. 일부 실패 요약·건강 확인·drift 조정은 작업 엔진 교착(흐름 22 결함)으로 완료되지 않음 (선행 흐름 22 에 막힘) | +| 31 | 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 | 부분 확인 | 정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인) | +| 32 | Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 | 부분 확인 | 활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인) | +| 33 | 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 | 부분 확인 | 전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인) | +| 34 | 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 | 부분 확인 | 해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인) (선행 흐름 6 에 막힘) | + +## 발견한 결함(재현 정보) + +- **Agent 가 포털 저장본(bundleUrl, 절대 URL)을 내려받지 못함(HAL 주소를 앞에 덧붙임)** — `agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle)` (HAL site.deploy → Agent fetch 단계) + - 기대: bundleUrl 이 절대 URL 이면 그대로 내려받는다(서명은 HAL 전용이므로 포털 번들 URL 에는 붙이지 않음) + - 실제: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni + - 재현: 포털 배포 → HAL site.deploy → Agent: "Failed to parse URL from http://http:///internal/bundles/…" · 영향 흐름 6, 8, 9, 13, 14, 15, 23 +- **포털 배포 작업이 실패한 HAL 작업을 실패로 보지 않고 5초마다 새 HAL 작업을 무한 재제출(배포가 영원히 "진행 중")** — `portal/sites/service.mjs (halJobFor: prev HAL 작업이 failed/cancelled 면 fresh 가 아니어도 새 작업을 만든다 → awaitHal 이 실패를 보지 못함)` (POST /api/sites/:id/deploy) + - 기대: HAL 작업이 failed 면 포털 작업도 failed(이전 버전 유지·재시도 버튼), 새 HAL 작업은 사용자 재시도(fresh) 때만 + - 실제: portal job queued attempt 9 (배포 서버 작업 진행 중); HAL 에 같은 사이트의 site.deploy 실패 작업이 attempt 수만큼 쌓임 + - 재현: Agent 가 실패하는 배포를 요청하고 /api/sites/:id/jobs/:id 를 지켜보면 attempt 가 5초마다 증가 · 영향 흐름 6, 23, 13, 20 +- **관리형 배포 종단 흐름 실패(포털→HAL→Agent 저장본 전달)** — `agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle: client.download(p.bundleUrl))` (site.deploy → Agent fetch 단계) + - 기대: 절대 URL(bundleUrl=http:///internal/bundles/)은 HAL base 를 붙이지 않고 그대로 내려받아야 한다 + - 실제: 시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: ["failed","Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni"]) + - 재현: 사이트 생성→저장본→POST /api/sites/:id/deploy→HAL site.deploy→Agent site.deploy fetch 단계에서 "HAL 내려받기 실패" 또는 잘못된 URL 오류 · 영향 흐름 6, 8, 9, 13, 14, 15, 23 +- **취소된 배포 작업의 재시도 요청이 500(서버 오류)으로 응답(작업 상태 오류는 4xx 여야 함)** — `portal/sites/api.mjs (jobs/:jobId/:action → lib/jobs JobError 를 HttpError 로 바꾸지 않음)` (POST /api/sites/:id/jobs/:jobId/retry) + - 기대: 409 job_not_retryable + 안내 + - 실제: 500 job_not_retryable + - 재현: 취소된 작업에 retry · 영향 흐름 23 +- **외부 Agent 토큰 발급이 DB 제약(external_agents.hal_agent_id NOT NULL)으로 500** — `portal/sites/service.mjs (createExternalAgent) / portal/sites/model.mjs (external_agents.hal_agent_id)` (POST /api/sites/:id/external) + - 기대: HAL 토큰 발급 응답의 agentId 가 null(등록 전)이어도 토큰·설치 명령을 돌려주고, Agent 등록 뒤 by-site 조회로 hal_agent_id 를 채운다 + - 실제: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ariweb-suite/lib/db.mjs:52:41) | at Object.createExternalAgent (file:///home/master/work/Ariweb + - 재현: 외부 배포 사이트 → 동의 → POST /api/sites/:id/external → 500 "NOT NULL constraint failed: external_agents.hal_agent_id" · 영향 흐름 16, 28 +- **HAL 작업 엔진이 작업을 한 번에 하나씩만 실행(tick 이 execute 를 await)해 느린 작업이 큐 전체를 막고, module.deploy 부모가 자식 완료를 기다리면 자식이 실행될 수 없어 교착** — `lib/jobs.mjs (createJobEngine.start/tick) / hal/jobs.mjs (fanout 이 자식 완료를 폴링)` (POST /ops/api/modules/:name/deploy) + - 기대: 엔진은 running 수가 concurrency 미만이면 실행을 분리(detach)해 여러 작업을 동시에 진행해야 한다(부모가 자식을 기다려도 자식이 실행됨) + - 실제: {"status":"queued","steps":["precheck:pending","fanout:pending","collect:pending"],"error":null,"children":[],"agentJobs":[],"timedOut":true} + - 재현: (1) Agent 가 수십 초 걸리는 domain.probe 가 queued 로 쌓이면 다른 모든 HAL 작업이 기다린다 (2) 큐가 비어도 모듈 배포는 부모 collect:running + 자식 queued 로 영원히 멈춘다(test/hal.test.mjs 는 pump() 로 tick 을 동시에 돌려 통과) · 영향 흐름 22, 30 + +## 실제 인프라가 필요한 항목 + +- 도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인은 실제 DNS 와 공인 서버가 필요하다(흐름 7·8·11·14·15) +- 메일 DNS(MX·SPF·DKIM·DMARC) 반영과 외부 송신·수신·회신 왕복은 실 도메인·실 AriMail(SMTP 587/수신함)·외부 검증 주소가 필요하다(흐름 10 활성화 완료·11·33 완료) +- 재해복구의 실제 복원(hal-restore.sh 로 다른 호스트에 HAL 재기동)·실제 운영자 로그인·HTTPS·고객 서비스 외부 확인은 실 서버가 필요하다(흐름 25 기록·훈련 절차만 로컬 확인) + +## 흐름별 증거(요약) + +### 1. 신규 고객 가입과 이메일 확인 + +- ✔ 가입(이메일·표시 이름·비밀번호·필수 동의) → 세션 발급 — {"status":200,"hasSession":"[redacted]"} +- ✔ 확인 전 유료·운영 기능 차단(그룹 생성 403 email_unverified) — email_unverified +- ✔ 확인 메일 수신(발신 포획)에서 토큰 추출 — {"subject":"[Ariweb] 이메일 주소를 확인해 주세요"} +- ✔ 이메일 확인(1회용) → 재사용 거부 — {"first":200,"second":"token_used"} +- ✔ 내 정보: 이메일 확인됨 · 개인 그룹 · owner · 별칭 user-01 — {"verified":true,"groupKind":"personal","role":"owner","alias":"user-01"} +- ✔ 중복 가입은 존재를 드러내지 않는 안내(409 signup_unavailable) — signup_unavailable +- ✔ 로그인 기기 목록 · 다른 세션 종료 — {"sessionsBefore":"[redacted]","revoked":2} + +```json +{ + "me": { + "verified": true, + "groupKind": "personal", + "role": "owner", + "alias": "user-01" + } +} +``` + +### 2. 개인 사용자 그룹 생성과 다른 사용자 초대 + +- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — g_01a17849a2c2dec27ee9f14777 +- ✔ 이메일 초대(7일) → 초대 메일 링크 — RAXi6X0pnmr-nCEKiIX63pk26vTbIB5d +- ✔ 초대 수락(1회용) → 별칭 user-02 · 역할 member — {"alias":"user-02","role":"member","reuse":"invite_invalid"} +- ✔ 권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할 — {"requiredRole":["owner","admin"],"yourRole":"member"} +- ✔ 그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음 — {"team":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0},"personal":{"live":0,"purchased":0,"inUse":0,"reserved":0,"unallocated":0}} + +### 3. 상품 주문, 결제 확인, 권리 활성화 + +- ✔ 상품 목록(공개 버전만·가격·한도·미확정 정책 표시) — {"count":2,"team":{"id":"pv_01a17849a127747d9c2bbbb24c","price":49000,"sites":3},"deferred":3,"paymentMethods":["manual_transfer"]} +- ✔ 주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음 — {"orderNo":"AW-20261026-0001","status":"awaiting_payment","total":63800,"dueAt":"2026-11-02T12:57:34.263Z","hasInstructions":true} +- ✔ 입금 확인 전 승인 거부(409 payment_not_confirmed) — payment_not_confirmed +- ✔ 운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시 — {"status":"awaiting_approval","delta":["sites","web_disk","web_out","mail_storage","mail_msgs","mailboxes","mail_services","mail_storage","mail_msgs","mailboxes"]} +- ✔ 운영자 승인 → 권리 활성화(재승인은 중복 없음) — {"granted":10,"already":true} +- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"sites":3,"subs":1,"nextBilling":"2026-11-26T12:57:34.327Z"} + +```json +{ + "ap": { + "granted": 10, + "already": true + }, + "bill": { + "sites": 3, + "subs": 1, + "nextBilling": "2026-11-26T12:57:34.327Z" + } +} +``` + +### 4. 초안 사이트 생성 + +- ✔ 사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내 — {"id":"s_01a17849a3be6f07d129dbdb60","lifecycle":"draft","projectId":"00000000000000000000000000000001","nextSteps":["build","import","domain","mode","perms"]} +- ✔ 사이트 목록 필터(초안)·사용량 요약에 예약 0 — {"drafts":1,"sitesReserved":0,"sitesAllocated":0} + +### 5. Webmaker에서 페이지 제작 + +- ✔ 제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용) — {"portalRedirect":"http://127.0.0.1:43619/api/auth/sso?ticket=…","webmaker":302,"reuse":401} +- ✔ Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과 — {"versions":1,"precheckOk":true} + +```json +{ + "sso": { + "portalRedirect": "http://127.0.0.1:43619/api/auth/sso?ticket=…", + "webmaker": 302, + "reuse": 401 + }, + "ver": { + "versions": 1, + "precheckOk": true + } +} +``` + +### 6. 관리형 배포 + +- ✔ 배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나 — {"jobId":"j_01a17849a4096e33f476b17d1b","steps":10,"again":false,"siteCount":"reserved","server":null} +- ✘ HAL·Agent 를 거쳐 작업 완료 대기(최대 40초) — 시간 초과 — 포털 작업 queued(attempt 8, 배포 서버 작업 진행 중) · HAL site.deploy 작업 8개(최근: ["failed","Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni"]) +- ✔ [HAL 직접] 같은 Agent 에 로컬 저장본으로 site.deploy → 성공하면 결함은 포털→Agent 번들 전달 구간 — {"status":"done","error":null,"layout":true,"externalCheck":{"ok":false,"status":404,"ms":19,"url":"http://direct_e2e.preview.localhost/","markerFound":false}} +- ✔ 사용자 취소(진행 중 작업) → 중간 결과 정리·예약 반환 — {"cancel":200,"cleanup":200,"siteCount":"reserved","lifecycle":"building"} + +```json +{ + "halLog": [ + "2026-10-26T12:58:01.231Z [jobs] site.deploy j_01a1784a0781bd4cb2d160762e 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", + "2026-10-26T12:58:06.242Z [jobs] site.deploy j_01a1784a1b1b6362b7ba3a604c 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni", + "2026-10-26T12:58:11.248Z [jobs] site.deploy j_01a1784a2eb913f75494650303 실패: Failed to parse URL from http://127.0.0.1:35361http://127.0.0.1:35287/internal/bundles/v74iR3dd-nVRU4N1LEVDmEe0ZuK300Ni" + ] +} +``` + +### 7. 도메인 연결과 HTTPS 발급 + +- ✔ 도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT) — {"domainId":"d_01a1784a491476e7f378e8217c","txtName":"_ariweb-verify.aw-e2e.localhost","txtValueShape":"ariweb-verify=…","copyable":true} +- ✔ 도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시 — {} +- ✔ (정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다 — {"queuedProbes":1} + +```json +{ + "dm": { + "domainId": "d_01a1784a491476e7f378e8217c", + "txtName": "_ariweb-verify.aw-e2e.localhost", + "txtValueShape": "ariweb-verify=…", + "copyable": true + } +} +``` + +### 9. 사이트 정보와 접속·사용량 통계 확인 + +- ✔ 개요: 배포 전/후에 따라 준비 단계 또는 실제 통계 — {"kind":"not_deployed","lifecycle":"building","hasStats":false,"readiness":[{"id":"build","label":"Webmaker 에서 사이트 제작","href":"/sites/s_01a17849a3be6f07d129dbdb60?tab=build","done":true},{"id":"import","label":"콘텐츠 가져오기","href":"/sites/s_01a17849a3be6f07d129dbdb60?tab=build","done":false,"unsupporte +- ✔ 사용량 탭(배정·실제 사용·마지막 계측) — {"allocations":["web_disk","web_out"]} + +```json +{ + "ov": { + "kind": "not_deployed", + "lifecycle": "building", + "hasStats": false, + "readiness": [ + { + "id": "build", + "label": "Webmaker 에서 사이트 제작", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", + "done": true + }, + { + "id": "import", + "label": "콘텐츠 가져오기", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=build", + "done": false, + "unsupported": "이번 버전에서 지원하지 않음" + }, + { + "id": "domain", + "label": "도메인 연결", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=domains", + "done": false + }, + { + "id": "mode", + "label": "배포 방식 선택", + "href": "/sites/s_01a17849a3be6f07d129dbdb60/deploy/mode", + "done": true + }, + { + "id": "perms", + "label": "구성원 권한 설정", + "href": "/sites/s_01a17849a3be6f07d129dbdb60?tab=settings", + "done": false + } + ], + "disk": null + }, + "us": { + "allocations": [ + "web_disk", + "web_out" + ] + } +} +``` + +### 10. 사이트 대표 메일과 메일함 생성 + +- ✔ 사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시 — {"id":"msv_01a1784ad1a3c31d4ea6cb7447","status":"needs_setup","tenant":true,"ariwebKind":"site_bound","limitAccounts":6,"siteCountAllocated":0} +- ✔ 같은 사이트에 두 번째 대표 메일 거부 — mail_domain_in_use +- ✔ 메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음 — {"id":"mb_01a1784ad1df7ea80529a8cf8a","address":"kim@aw-e2e.localhost","passwordShownOnce":"[redacted]","mustChange":true,"imapHost":"127.0.0.1","leakedOnReload":false,"inArimail":true} +- ✔ 초대 방식 메일함(링크 만료·재발송·사용 완료 상태) — {"inviteState":"pending","accepted":200,"reuse":"invite_used"} +- ✔ DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing) — {"ok":false,"missing":3,"items":["mx:missing","spf:missing","dkim:missing","dmarc:recommended"]} +- ✔ 활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등 — {"status":"awaiting_external","waitReason":"DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요","sameJob":true,"steps":["entitlement:done","dns:waiting","prepare:pending","verify:pending","activate:pending"]} +- ✔ 보안 보고서(SPF·DKIM·DMARC·MX 상태 + 복사 값) — {"items":["sender:error","mx:error","spf:error","dkim:error","dmarc:recommended","tls:error","roundtrip:recommended"],"records":4} + +```json +{ + "svc": { + "id": "msv_01a1784ad1a3c31d4ea6cb7447", + "status": "needs_setup", + "tenant": true, + "ariwebKind": "site_bound", + "limitAccounts": 6, + "siteCountAllocated": 0 + }, + "mb": { + "id": "mb_01a1784ad1df7ea80529a8cf8a", + "address": "kim@aw-e2e.localhost", + "passwordShownOnce": "[redacted]", + "mustChange": true, + "imapHost": "127.0.0.1", + "leakedOnReload": false, + "inArimail": true + }, + "dns": { + "ok": false, + "missing": 3, + "items": [ + "mx:missing", + "spf:missing", + "dkim:missing", + "dmarc:recommended" + ] + }, + "act": { + "status": "awaiting_external", + "waitReason": "DNS 레코드가 아직 반영되지 않았습니다(누락 3, 잘못된 값 0). 도메인 DNS 에 안내된 값을 넣은 뒤 '이어서 진행' 을 누르세요", + "sameJob": true, + "steps": [ + "entitlement:done", + "dns:waiting", + "prepare:pending", + "verify:pending", + "activate:pending" + ] + } +} +``` + +### 12. 독립 메일 서비스 생성 + +- ✔ 메일 전용 도메인 등록(solo-e2e.localhost, purpose mail) — {"id":"d_01a1784af801ae5cf98ef55e1f","jobCancelled":null} +- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1784af809f14b89bc606f73","kind":"standalone","mailServicesAllocated":1,"sitesAllocated":0} + +```json +{ + "r": { + "id": "msv_01a1784af809f14b89bc606f73", + "kind": "standalone", + "mailServicesAllocated": 1, + "sitesAllocated": 0 + }, + "listCount": 2, + "noBackupNote": "메일 원문은 고객용 백업·복원에 포함되지 않으며, 메일 백업 기능을 제공하지 않습니다." +} +``` + +### 13. 백업 옵션 구매와 첫 백업 확인 + +- ✔ 백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리 — {"granted":7,"orderStatus":"approved"} +- ✔ 백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서) — {"eligible":true,"reasons":[],"deferred":[],"policy":null,"status":200} +- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 시각 — {"status":"active","firstDue":"2026-10-27T12:59:01.665Z","nextRun":"2026-10-26T12:59:01.665Z"} +- ✔ 지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증 — {"timedOut":true,"optionStatus":"active","lastJob":null,"siteServer":null,"backup":null,"job":null} + +```json +{ + "el": { + "eligible": true, + "reasons": [], + "deferred": [], + "policy": null, + "status": 200 + }, + "opt": { + "status": "active", + "firstDue": "2026-10-27T12:59:01.665Z", + "nextRun": "2026-10-26T12:59:01.665Z" + }, + "run1": { + "timedOut": true, + "optionStatus": "active", + "lastJob": null, + "siteServer": null, + "backup": null, + "job": null + } +} +``` + +### 15. 관리형 사이트 서버 이전과 외부 확인 + +- ✔ 이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원) — {"auto":{"ok":false,"reasons":["사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다"]},"boundaries":{"lastCancel":"새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다","revertHours":72,"cost":10000,"platformCost":0}} +- ✔ 고객 요청(유료·승인 대기) 또는 조건 미충족 사유 — {"status":200,"relocation":{"status":"awaiting_approval","kind":"customer_paid","cost":10000}} +- ✔ 전환 전 고객 취소 가능(요청 취소 → 소유권 이전 등 다른 작업 차단 해제) — {"status":200,"rstatus":"cancelled"} + +```json +{ + "b": { + "auto": { + "ok": false, + "reasons": [ + "사이트 대표 메일이 있는 사이트는 승인형으로 진행합니다" + ] + }, + "boundaries": { + "lastCancel": "새 위치 확인(연결 전환 직전)까지 취소할 수 있습니다", + "revertHours": 72, + "cost": 10000, + "platformCost": 0 + } + }, + "req": { + "status": 200, + "relocation": { + "status": "awaiting_approval", + "kind": "customer_paid", + "cost": 10000 + } + } +} +``` + +### 16. 외부 배포 사이트 생성과 배포 + +- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1784b5d03e229abe92a46a1","group_id":"g_01a17849a2c2dec27ee9f14777","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou +- ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false} +- ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200} +- ✘ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — 500 {"ok":false,"error":"ERR_SQLITE_ERROR","message":"일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요"} | portal: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ar +- ✔ [HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키) — {"token":"[redacted]","expiresAt":"2026-10-26T13:09:27.408Z"} +- ✔ [HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected — {"agentId":"[redacted]","status":"connected","siteId":"s_01a1784b5d03e229abe92a46a1"} +- ✔ [HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성 — {"status":"done","error":null,"errorCode":null,"result":{"version":"20261026125937-v1","leftovers":[]},"layout":true} +- ✔ [HAL 직접] 재배포(v2) → 이전 버전 기록 — {"status":"done","error":null,"errorCode":null,"result":{"version":"20261026125950-v2","leftovers":[]}} +- ✔ [HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고 — {"status":"failed","error":"내부 확인 실패(404) — 이전 버전을 유지합니다","errorCode":"internal_check_failed","result":null} +- ✔ [HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부 — {} +- ✔ [HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거 — {"layoutAfter":false} + +```json +{ + "mode": { + "notProvided": "[redacted]", + "consentVersion": "external-deploy-2026-10-0.1.1", + "agreed": false + } +} +``` + +### 17. 사용량 경고와 한도 동작 확인 + +- ✔ 가짜 AriMail 사용량 95%/월간 100% → 수집(refresh) → 구간·제한 사유·다음 KST 초기화·마지막 계측 표시 — {"storageLevel":95,"monthlyLevel":100,"nextReset":"2026-10-31T15:00:00.000Z","measuredAt":true,"limit100":"월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.","countingRule":"per_final_recipient","evaluate":{"status":200,"notified":2,"limited":1}} +- ✔ 알림 센터: 95%·100% 경고가 사건으로 묶여 표시(필수 알림) — {"count":3,"titles":["aw-e2e.localhost 메일 저장 공간 96% — 곧 제한됩니다(최종 경고)","aw-e2e.localhost 월간 메일 통수(송신+수신) 100% — 제한 적용","메일함 kim@aw-e2e.localhost 저장 공간 95% 경고"],"mandatory":true} +- ✔ 사용량 화면 제한 플래그(/api/usage/limits)·그룹 요약 — {"limits":["mail_msgs:100","mail_storage:95"],"warnLevels":[70,80,90,95]} + +```json +{ + "warn": { + "storageLevel": 95, + "monthlyLevel": 100, + "nextReset": "2026-10-31T15:00:00.000Z", + "measuredAt": true, + "limit100": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", + "countingRule": "per_final_recipient", + "evaluate": { + "status": 200, + "notified": 2, + "limited": 1 + } + }, + "notif": { + "count": 3, + "titles": [ + "aw-e2e.localhost 메일 저장 공간 96% — 곧 제한됩니다(최종 경고)", + "aw-e2e.localhost 월간 메일 통수(송신+수신) 100% — 제한 적용", + "메일함 kim@aw-e2e.localhost 저장 공간 95% 경고" + ], + "mandatory": true + }, + "lim": { + "limits": [ + "mail_msgs:100", + "mail_storage:95" + ], + "warnLevels": [ + 70, + 80, + 90, + 95 + ] + } +} +``` + +### 18. 상품 축소 보류와 정리 후 적용 + +- ✔ 배정 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대) + 마지막 단위 동시 요청 — {"purchased":45,"mine":6,"others":2,"unallocated":37,"race":[409,409],"mineAfter":6} +- ✔ 축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시 — {"shortages":["mailboxes:1"]} +- ✔ 축소 요청 → 보류(사용 중 권리 즉시 회수 없음) — {"fits":false,"hold":"holding","sitesStillPurchased":3} +- ✔ 정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성 — {"allocate":200,"fits":true,"shortages":[],"order":"awaiting_payment"} +- ✔ 축소 주문 입금 확인·승인 → 권리 교체(베이직) — {"approve":200,"sites":1,"mailboxes":5,"product":"pv_01a17849a126e838aa8bb17065"} + +```json +{ + "alloc": { + "purchased": 45, + "mine": 6, + "others": 2, + "unallocated": 37, + "race": [ + 409, + 409 + ], + "mineAfter": 6 + }, + "pv": { + "shortages": [ + "mailboxes:1" + ] + }, + "hold": { + "fits": false, + "hold": "holding", + "sitesStillPurchased": 3 + }, + "clean": { + "allocate": 200, + "fits": true, + "shortages": [], + "order": "awaiting_payment" + }, + "applied": { + "approve": 200, + "sites": 1, + "mailboxes": 5, + "product": "pv_01a17849a126e838aa8bb17065" + } +} +``` + +### 19. 사이트 수 반환과 대표 메일의 독립 전환 + +- ✔ §14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션) — {"ok":false,"blocking":["domains_unrouted","mail_choice","mail_converted"],"steps":8} +- ✔ 메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙 — {"status":409,"error":"quota_insufficient","detail":["resource","needed","available","link"]} + +```json +{ + "ck": { + "ok": false, + "blocking": [ + "domains_unrouted", + "mail_choice", + "mail_converted" + ], + "steps": 8 + }, + "keep": { + "status": 409, + "error": "quota_insufficient", + "detail": [ + "resource", + "needed", + "available", + "link" + ] + } +} +``` + +### 20. 사이트 소유권 이전 + +- ✔ 이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목) — {"sites":1,"domains":1,"siteMail":1,"needed":{"backup":1,"web_disk":1073741824,"mail_msgs":500,"mail_storage":2147483648,"mailboxes":6},"excluded":3} +- ✔ 요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림 — {"id":"tr_01a1784c7a9e2007160a39f536","status":"requested","expiresAt":"2026-11-02T13:00:40.478Z","bobNotified":true} +- ✔ (정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개) — {"endBackup":200,"alloc":200} +- ✔ 받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내) — {"status":200,"tstatus":"reviewing","hold":null} +- ✔ 운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각 — {"bad":"final_confirmation_mismatch","ok":200,"status":"completed","cutover":"2026-10-26T13:00:40.527Z"} +- ✔ 완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사 — {"aliceSees":404,"bobSees":200,"bobUsageSites":0,"audit":true} + +```json +{ + "pv": { + "sites": 1, + "domains": 1, + "siteMail": 1, + "needed": { + "backup": 1, + "web_disk": 1073741824, + "mail_msgs": 500, + "mail_storage": 2147483648, + "mailboxes": 6 + }, + "excluded": 3 + }, + "rq": { + "id": "tr_01a1784c7a9e2007160a39f536", + "status": "requested", + "expiresAt": "2026-11-02T13:00:40.478Z", + "bobNotified": true + }, + "acc": { + "status": 200, + "tstatus": "reviewing", + "hold": null + }, + "ap": { + "bad": "final_confirmation_mismatch", + "ok": 200, + "status": "completed", + "cutover": "2026-10-26T13:00:40.527Z" + }, + "after": { + "aliceSees": 404, + "bobSees": 200, + "bobUsageSites": 0, + "audit": true + } +} +``` + +### 21. 중지, 해지, 보존 예정일 표시 + +- ✔ §23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림 — {"state":"제작 중","siteCount":false,"publicAccess":"중지됨","retention":{"site_days":30,"mail_days":30,"source_days":30,"backup_days":7,"delete_grace_days":1,"notice_before_days":3,"readonly_retention_days":30,"delete_after_days":30,"pause_keeps_site_count":true,"cancel_effective":"next_billing_date","so +- ✔ 일시 중지(확인 필수) 또는 현재 상태에서 거부 사유 — {"noConfirm":"confirmation_required","status":409,"error":"lifecycle_transition"} +- ✔ 메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공) — {"status":200,"sendReceive":"송수신 가능","retentionUntil":false,"deleteAfter":false,"backupNote":"고객용 메일 백업·복원은 제공하지 않습니다(§17.4)"} + +```json +{ + "v": { + "state": "제작 중", + "siteCount": false, + "publicAccess": "중지됨", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + }, + "pause": { + "noConfirm": "confirmation_required", + "status": 409, + "error": "lifecycle_transition" + }, + "mailLc": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } +} +``` + +### 22. HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 + +- ✔ 릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서 — {"releases":["0.1.2:approved","0.1.1:approved"],"running":{"complete":true,"firstInstall":false,"releaseVersion":null,"mainCommit":null,"artifactSha256":null,"halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"order":["hal","agent","portal","webmaker","arimail","backup +- ✔ HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부 — {"status":"failed","error":"hal_first"} +- ✔ 승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시 — {"manualInstallLocked":true,"complete":true} +- ✔ HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인) — {"status":"queued","steps":["precheck:pending","fanout:pending","collect:pending"],"error":null,"children":[],"agentJobs":[],"timedOut":true} + +```json +{ + "rel": { + "releases": [ + "0.1.2:approved", + "0.1.1:approved" + ], + "running": { + "complete": true, + "firstInstall": false, + "releaseVersion": null, + "mainCommit": null, + "artifactSha256": null, + "halRunningVersion": "0.1.1", + "firstAgent": "[redacted]", + "manualInstallLocked": true + }, + "order": [ + "hal", + "agent", + "portal", + "webmaker", + "arimail", + "backup", + "observability" + ] + }, + "hf": { + "status": "failed", + "error": "hal_first" + }, + "na": { + "manualInstallLocked": true, + "complete": true + }, + "dep": { + "status": "queued", + "steps": [ + "precheck:pending", + "fanout:pending", + "collect:pending" + ], + "error": null, + "children": [], + "agentJobs": [], + "timedOut": true + }, + "diag": { + "stuckNow": [ + { + "id": "j_01a1784d7f02143ed47d1fcaf9", + "kind": "domain.probe", + "status": "queued", + "attempt": 0, + "step": "dispatch", + "detail": "", + "updatedAt": "2026-10-26T13:01:47.138Z" + }, + { + "id": "j_01a1784d6b6a20949d03d2dee8", + "kind": "domain.probe", + "status": "queued", + "attempt": 0, + "step": "dispatch", + "detail": "", + "updatedAt": "2026-10-26T13:01:42.122Z" + }, + { + "id": "j_01a1784d671c8f951049094a53", + "kind": "module.deploy", + "status": "queued", + "attempt": 0, + "step": "precheck", + "detail": "", + "updatedAt": "2026-10-26T13:01:41.020Z" + }, + { + "id": "j_01a1784d57cf28f7528a1a8ccb", + "kind": "domain.probe", + "status": "queued", + "attempt": 0, + "step": "dispatch", + "detail": "", + "updatedAt": "2026-10-26T13:01:37.103Z" + }, + { + "id": "j_01a1784d44338bf61b6691d5a8", + "kind": "domain.probe", + "status": "running", + "attempt": 1, + "step": "await", + "detail": "", + "updatedAt": "2026-10-26T13:01:4 +``` + +### 23. 실패 작업의 재시도와 되돌리기 + +- ✔ 배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호 — {"status":"cancelled","failedStep":null,"canRetry":false,"jobId":"j_01a17849a4096e33f476b17d1b"} +- ✔ 재시도 요청(POST /api/sites/:id/jobs/:jobId/retry) — {"status":500,"error":"job_not_retryable"} +- ✔ 되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부 — {"status":409,"error":"no_previous","message":"되돌릴 이전 버전이 없습니다"} + +```json +{ + "dep": { + "status": "cancelled", + "failedStep": null, + "canRetry": false, + "jobId": "j_01a17849a4096e33f476b17d1b" + }, + "rt": { + "status": 500, + "error": "job_not_retryable" + }, + "rb": { + "status": 409, + "error": "no_previous", + "message": "되돌릴 이전 버전이 없습니다" + } +} +``` + +### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 + +- ✔ 고객 알림 센터에 승인 알림 — {"title":"주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다","category":"billing","link":"/orders/o_01a17849a33601ca26016fa94a"} +- ✔ 감사 기록: order.approve 는 삭제 불가(immutable) — {"action":"order.approve","immutable":true} +- ✔ 운영자 화면과 고객 주문 화면의 상태 일치 — {"status":"approved","approvedAt":"2026-10-26T12:57:34.327Z"} + +```json +{ + "n": { + "title": "주문 AW-20261026-0001 승인 — 권리가 활성화되었습니다", + "category": "billing", + "link": "/orders/o_01a17849a33601ca26016fa94a" + }, + "a": { + "action": "order.approve", + "immutable": true + }, + "same": { + "status": "approved", + "approvedAt": "2026-10-26T12:57:34.327Z" + } +} +``` + +### 25. HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 + +- ✔ 설정 전 준비 상태(미설정 항목) — {"ready":false,"missingConfig":["rto_minutes","rpo_minutes","snapshot_interval_minutes","retention_count"],"lastDrill":null,"lastSnapshot":null,"config":{"rto_minutes":null,"rpo_minutes":null,"snapshot_interval_minutes":null,"retention_count":null,"external_store":null},"label":"정식 운영 준비 전(복구 훈련 또는 +- ✔ RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정 — {"id":1,"rto_minutes":60,"rpo_minutes":30,"snapshot_interval_minutes":60,"retention_count":3,"external_store":"/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026125729/dr-external","updated_at":"2026-10-26T13:02:07.823Z","updated_by":"op_01a178499a27f1ba88344e1145"} +- ✔ 복구 사본 생성·검증·외부 보관(vault.key 제외) — {"verify":"ok","external":true,"sha":true} +- ✔ 복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료 — {"early":"restore_not_approved","noStop":"old_control_not_confirmed","command":true,"incomplete":"checks_incomplete","final":"completed_with_findings"} +- ✔ 복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료 — {"first":"failed","second":"success","ready":true,"measured":{}} + +```json +{ + "r0": { + "ready": false, + "missingConfig": [ + "rto_minutes", + "rpo_minutes", + "snapshot_interval_minutes", + "retention_count" + ], + "lastDrill": null, + "lastSnapshot": null, + "config": { + "rto_minutes": null, + "rpo_minutes": null, + "snapshot_interval_minutes": null, + "retention_count": null, + "external_store": null + }, + "label": "정식 운영 준비 전(복구 훈련 또는 설정 필요)" + }, + "cfg": { + "id": 1, + "rto_minutes": 60, + "rpo_minutes": 30, + "snapshot_interval_minutes": 60, + "retention_count": 3, + "external_store": "/home/master/work/Ariweb-suite/test/.tmp/e2e-20261026125729/dr-external", + "updated_at": "2026-10-26T13:02:07.823Z", + "updated_by": "op_01a178499a27f1ba88344e1145" + }, + "snap": { + "verify": "ok", + "external": true, + "sha": true + }, + "rs": { + "early": "restore_not_approved", + "noStop": "old_control_not_confirmed", + "command": true, + "incomplete": "checks_incomplete", + "final": "completed_with_findings" + }, + "drill": { + "first": "failed", + "second": "success", + "ready": true, + "measured": {} + } +} +``` + +### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 + +- ✔ 재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록 — {"mailJob":{"id":"j_01a1784ae61b14370f923139a1","status":"awaiting_external"},"halJobs":50} +- ✔ 포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지 — {"sessionKept":"[redacted]","mailJob":{"id":"j_01a1784ae61b14370f923139a1","status":"awaiting_external"},"same":true} +- ✔ HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지 — {"opsSessionKept":"[redacted]","halJobs":50,"sameCount":true} + +```json +{ + "before": { + "mailJob": { + "id": "j_01a1784ae61b14370f923139a1", + "status": "awaiting_external" + }, + "halJobs": 50 + }, + "pr": { + "sessionKept": "[redacted]", + "mailJob": { + "id": "j_01a1784ae61b14370f923139a1", + "status": "awaiting_external" + }, + "same": true + }, + "hr": { + "opsSessionKept": "[redacted]", + "halJobs": 50, + "sameCount": true + } +} +``` + +### 27. 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 + +- ✔ 밥 개인 그룹: 베이직(사이트 1) 주문·승인 — {"granted":6} +- ✔ 사이트 2개 생성·저장본 — [{"id":"s_01a1784ac5a389af03b57b32a3","group_id":"g_01a17849a2ee454ab48b65c13d","name":"동시 A","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000002","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_sta +- ✔ 두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족) — {"ok":1,"blocked":"precheck_failed","shortage":"block"} +- ✔ (정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환 — {"cancel":200,"cleanup":200,"unallocated":0} + +```json +{ + "race": { + "ok": 1, + "blocked": "precheck_failed", + "shortage": "block" + } +} +``` + +### 28. 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 + +- ✔ 외부 배포 사이트 생성(초안) + 저장본 — {"id":"s_01a1784b5d03e229abe92a46a1","group_id":"g_01a17849a2c2dec27ee9f14777","name":"E2E 외부 사이트","purpose":"e2e","webmaker_project_id":"00000000000000000000000000000004","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"external","primary_domain":null,"site_cou +- ✔ 배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전 — {"notProvided":"[redacted]","consentVersion":"external-deploy-2026-10-0.1.1","agreed":false} +- ✔ 동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전) — {"before":"consent_required","consent":200} +- ✘ 외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음) — 500 {"ok":false,"error":"ERR_SQLITE_ERROR","message":"일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요"} | portal: 2026-10-26T12:59:27.404Z [http] POST /api/sites/s_01a1784b5d03e229abe92a46a1/external 500: Error: NOT NULL constraint failed: external_agents.hal_agent_id | at Object.run (file:///home/master/work/Ar +- ✔ [HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키) — {"token":"[redacted]","expiresAt":"2026-10-26T13:09:27.408Z"} +- ✔ [HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected — {"agentId":"[redacted]","status":"connected","siteId":"s_01a1784b5d03e229abe92a46a1"} +- ✔ [HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성 — {"status":"done","error":null,"errorCode":null,"result":{"version":"20261026125937-v1","leftovers":[]},"layout":true} +- ✔ [HAL 직접] 재배포(v2) → 이전 버전 기록 — {"status":"done","error":null,"errorCode":null,"result":{"version":"20261026125950-v2","leftovers":[]}} +- ✔ [HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고 — {"status":"failed","error":"내부 확인 실패(404) — 이전 버전을 유지합니다","errorCode":"internal_check_failed","result":null} +- ✔ [HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부 — {} +- ✔ [HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거 — {"layoutAfter":false} + +```json +{ + "halTok": { + "expiresAt": "2026-10-26T13:09:27.408Z" + }, + "d1": { + "status": "done", + "error": null, + "errorCode": null, + "result": { + "version": "20261026125937-v1", + "leftovers": [] + }, + "layout": true + }, + "d2": { + "status": "done", + "error": null, + "errorCode": null, + "result": { + "version": "20261026125950-v2", + "leftovers": [] + } + }, + "d3": { + "status": "failed", + "error": "내부 확인 실패(404) — 이전 버전을 유지합니다", + "errorCode": "internal_check_failed", + "result": null + }, + "d4": {}, + "d5": { + "layoutAfter": false + } +} +``` + +### 29. HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 + +- ✔ HAL 중단 중: 기존 조회(사이트 목록·개요)는 제공, 배포 전 확인/배포는 정보 확인 지연·차단 — {"list":200,"overview":200,"precheck":{"status":200,"blocks":["target:HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정"]},"deploy":{"status":409,"error":"precheck_failed","message":"배포 전 확인에서 차단된 항목이 있습니다"}} +- ✔ 대시보드/알림에 중앙 상태 확인 지연 표시(가능하면) — {"status":200,"halState":null} +- ✔ HAL 재기동(같은 호스트의 죽은 인스턴스 임대 이어받기) → 서버·Agent 상태 복귀 — {"takeoverNeeded":false,"bootstrap":true,"readyServers":4} + +```json +{ + "during": { + "list": 200, + "overview": 200, + "precheck": { + "status": 200, + "blocks": [ + "target:HAL(중앙 관리)에 연결할 수 없어 배포 위치를 확인하지 못했습니다(정" + ] + }, + "deploy": { + "status": 409, + "error": "precheck_failed", + "message": "배포 전 확인에서 차단된 항목이 있습니다" + } + }, + "dash": { + "status": 200, + "halState": null + }, + "back": { + "takeoverNeeded": false, + "bootstrap": true, + "readyServers": 4 + } +} +``` + +### 30. 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 + +- ✔ agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed) — {"status":"queued","errorCode":null,"overall":"none","children":[],"timedOut":true} +- ✔ 모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업 — {"health":"timeout","drift":0,"reconcile":200,"overviewComponents":["HAL:ok","portal:unknown","webmaker:unknown","arimail:unknown","backup:ok","observability:ok"]} +- ✔ 되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부 — {"step1":"confirm_impact_required","step2":"identifiers_mismatch"} + +```json +{ + "d": { + "status": "queued", + "errorCode": null, + "overall": "none", + "children": [], + "timedOut": true + }, + "h": { + "health": "timeout", + "drift": 0, + "reconcile": 200, + "overviewComponents": [ + "HAL:ok", + "portal:unknown", + "webmaker:unknown", + "arimail:unknown", + "backup:ok", + "observability:ok" + ] + }, + "rb": { + "step1": "confirm_impact_required", + "step2": "identifiers_mismatch" + } +} +``` + +### 31. 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 + +- ✔ HAL 정상 상태에서 선언 거부(hal_healthy) — hal_healthy +- ✔ critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회) — {"id":"em_01a1784dd0ec3f08f6ddf4281a","noCred":"approvals_insufficient","wrongStep":"wrong_step","badIds":"identifiers_mismatch","badText":"final_confirm_mismatch","approved":"approved","approvals":3,"credential":"[redacted]","expiresAt":"2026-10-26T13:32:08.135Z"} +- ✔ 일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료 — {"forbidden":"action_forbidden","outOfScope":"out_of_scope","badCred":"bad_credential","performed":400,"revoked":true,"afterRevoke":"credential_revoked","reconcile":200,"closed":"closed"} + +```json +{ + "healthy": "hal_healthy", + "sess": { + "id": "em_01a1784dd0ec3f08f6ddf4281a", + "noCred": "approvals_insufficient", + "wrongStep": "wrong_step", + "badIds": "identifiers_mismatch", + "badText": "final_confirm_mismatch", + "approved": "approved", + "approvals": 3, + "expiresAt": "2026-10-26T13:32:08.135Z" + }, + "perf": { + "forbidden": "action_forbidden", + "outOfScope": "out_of_scope", + "badCred": "bad_credential", + "performed": 400, + "revoked": true, + "afterRevoke": "credential_revoked", + "reconcile": 200, + "closed": "closed" + } +} +``` + +### 32. Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 + +- ✔ Source 옵션 주문(대상 사이트)·승인 → source 권리 — {"granted":7} +- ✔ Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보 — {"activate":200,"status":"active","provider":"gitea","changes":1,"clone":true} +- ✔ export(저장본 내려받기) — {"status":200,"type":"application/gzip"} +- ✔ 옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단 — {"status":"readonly","deleteAfter":"2026-11-25T13:00:40.412Z","readonlyUntil":"2026-11-25T13:00:40.412Z"} +- ✔ 삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음 — {"sweep":200,"statusAfter":"readonly"} + +```json +{ + "act": { + "activate": 200, + "status": "active", + "provider": "gitea", + "changes": 1, + "clone": true + }, + "ex": { + "status": 200, + "type": "application/gzip" + }, + "un": { + "status": "readonly", + "deleteAfter": "2026-11-25T13:00:40.412Z", + "readonlyUntil": "2026-11-25T13:00:40.412Z" + }, + "sweep": { + "sweep": 200, + "statusAfter": "readonly" + } +} +``` + +### 33. 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 + +- ✔ 사이트 대표→독립 전환 점검표(주소·메일함·별칭·전달·비밀번호·DNS·옵션·작업) — {"canConvert":false,"items":["addresses:ok","data:ok","aliases:ok","forwards:ok","passwords:ok","dns:x","entitlement:x","jobs:x"]} +- ✔ DNS 미확인 상태에서 전환 요청은 차단 — {"status":409,"error":"conversion_blocked"} + +```json +{ + "ck": { + "canConvert": false, + "items": [ + "addresses:ok", + "data:ok", + "aliases:ok", + "forwards:ok", + "passwords:ok", + "dns:x", + "entitlement:x", + "jobs:x" + ] + }, + "tr": { + "status": 409, + "error": "conversion_blocked" + } +} +``` + +### 34. 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 + +- ✔ §23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림 — {"state":"제작 중","siteCount":false,"publicAccess":"중지됨","retention":{"site_days":30,"mail_days":30,"source_days":30,"backup_days":7,"delete_grace_days":1,"notice_before_days":3,"readonly_retention_days":30,"delete_after_days":30,"pause_keeps_site_count":true,"cancel_effective":"next_billing_date","so +- ✔ 일시 중지(확인 필수) 또는 현재 상태에서 거부 사유 — {"noConfirm":"confirmation_required","status":409,"error":"lifecycle_transition"} +- ✔ 메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공) — {"status":200,"sendReceive":"송수신 가능","retentionUntil":false,"deleteAfter":false,"backupNote":"고객용 메일 백업·복원은 제공하지 않습니다(§17.4)"} + +```json +{ + "v": { + "state": "제작 중", + "siteCount": false, + "publicAccess": "중지됨", + "retention": { + "site_days": 30, + "mail_days": 30, + "source_days": 30, + "backup_days": 7, + "delete_grace_days": 1, + "notice_before_days": 3, + "readonly_retention_days": 30, + "delete_after_days": 30, + "pause_keeps_site_count": true, + "cancel_effective": "next_billing_date", + "source": "상품정책 베이직 (retention 정책) v1" + }, + "pauseImpactKeys": [ + "publicAccess", + "adminAccess", + "mail", + "backups", + "usageAndCost", + "resume", + "siteCount" + ], + "cancelImpactKeys": [ + "target", + "linked", + "immediateStops", + "remainingData", + "retentionUntil", + "deleteAfter", + "retentionSource", + "export", + "unpaid", + "refund", + "pointOfNoReturn", + "mailOptions", + "siteCount" + ], + "steps14": 8 + }, + "mailLc": { + "status": 200, + "sendReceive": "송수신 가능", + "retentionUntil": false, + "deleteAfter": false, + "backupNote": "고객용 메일 백업·복원은 제공하지 않습니다(§17.4)" + } +} +``` + diff --git a/test/e2e.test.mjs b/test/e2e.test.mjs new file mode 100644 index 0000000..5485a2b --- /dev/null +++ b/test/e2e.test.mjs @@ -0,0 +1,45 @@ +/** + * test/e2e.test.mjs — 로컬 종단 간 검증 진입점. + * + * 실제 HAL·포털·Node Agent 프로세스와 가짜 Webmaker/AriMail/메일 서버를 띄우고 test/e2e/flows/*.mjs 의 흐름을 순서대로 실행한 뒤 + * docs/verification/local-e2e-<날짜>.md/.json 에 흐름별 판정과 결함을 기록한다. + * ARIWEB_E2E=0 건너뜀 + * ARIWEB_E2E_FLOWS=a,b 특정 흐름 그룹만(파일 이름) + * ARIWEB_E2E_STRICT=1 'failed' 판정이 있으면 종료 코드 1(기본은 보고서만 쓰고 통과 — 결함은 보고서가 알린다) + * ARIWEB_E2E_KEEP=1 임시 디렉터리 보존 + * 전체 5분 안에 끝나도록 흐름마다 시간 제한을 둔다. + */ +import { test, ok, done } from '../lib/test.mjs'; +import { readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bootEnv } from './e2e/boot.mjs'; +import { createReport } from './e2e/report.mjs'; + +if (process.env.ARIWEB_E2E === '0') { console.log(' (ARIWEB_E2E=0 — 로컬 종단 간 검증 건너뜀)'); done(); } +else { + const HERE = dirname(fileURLToPath(import.meta.url)); const report = createReport(); const startedAt = Date.now(); const log = (...a) => console.log(' ', ...a); + const only = (process.env.ARIWEB_E2E_FLOWS || '').split(',').map((s) => s.trim()).filter(Boolean); + const files = readdirSync(join(HERE, 'e2e', 'flows')).filter((f) => f.endsWith('.mjs')).sort().filter((f) => !only.length || only.some((o) => f.includes(o))); + let env = null; const state = {}; + await test('환경 기동: HAL·Agent 4대·포털·가짜 서비스', async () => { + env = await bootEnv({ log }); report.env.agents = env.agents.length; report.env.ports = env.ports; report.env.mailServerRegistered = env.mailServerRegistered; report.env.node = process.version; + ok(env.hal.proc.exited === null, 'HAL 실행 중'); ok(env.portal.proc.exited === null, '포털 실행 중'); + const h = await env.ops.get('/api/health'); ok(h.json.bootstrap.complete, '부트스트랩 완료: ' + JSON.stringify(h.json.bootstrap)); + if (!env.mailServerRegistered) report.bug({ title: '운영자 중계로 AriMail 서버 등록 실패', file: 'portal/mail/api.mjs', route: 'POST /ops/api/portal/mail/servers → /internal/ops/mail/servers', expected: '200 server', actual: env.mailServerResponse, repro: 'boot.mjs 의 AriMail 서버 등록' }); + }); + for (const f of files) { + await test(`흐름 그룹 ${f}`, async () => { + if (!env) throw new Error('환경 없음'); const mod = await import(join(HERE, 'e2e', 'flows', f)); + const budget = Number(mod.BUDGET_MS || 60000); let timer; const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`흐름 그룹 ${f} 시간 초과(${budget}ms)`)), budget); }); + try { await Promise.race([mod.run({ env, report, state, log }), timeout]); } finally { clearTimeout(timer); } + log(`${f}: ${Math.round((Date.now() - startedAt) / 1000)}s 경과`); + }); + } + await test('보고서 기록', async () => { + const out = report.write(join(HERE, '..', 'docs', 'verification')); log(`보고서: ${out.md}`); log('요약: ' + JSON.stringify(out.summary)); log('결함: ' + report.bugs.length + '건'); + if (process.env.ARIWEB_E2E_STRICT === '1') ok(out.summary.failed === 0, `failed 판정 ${out.summary.failed}건`); + }); + if (env) await env.stop(); + done(); +} diff --git a/test/e2e/boot.mjs b/test/e2e/boot.mjs new file mode 100644 index 0000000..53e9565 --- /dev/null +++ b/test/e2e/boot.mjs @@ -0,0 +1,105 @@ +/** + * test/e2e/boot.mjs — 로컬 종단 간 검증 환경 기동. + * + * 한 번의 실행에서 다음을 임시 디렉터리(test/.tmp/e2e-*)와 빈 포트로 띄운다. + * 1) 가짜 서비스: 발신 메일 포획(Resend 호환) · Webmaker 연동 면 · AriMail 연동 면 + * 2) HAL(실제 프로세스): 보관소에 서비스 키를 미리 넣고, CLI 로 설정 토큰·릴리스 등록(승인)·부트스트랩 릴리스 기록 → 서버 실행 → 운영자 최초 설정·로그인 + * 3) Node Agent(실제 프로세스, ARIWEB_AGENT_STUB_CMDS=1): control/web/backup/web 역할 서버 4대를 등록·승인(첫 승인으로 부트스트랩 완료) + * 4) 고객 포털(실제 프로세스): HAL·Webmaker·AriMail 주소를 가짜에 맞추고 Catalog 자동 시드, AriMail 서버를 운영자 중계로 등록 + * 서비스 키 배치: HAL 보관소 service:portal / service:hal 과 포털 보관소 service:hal / service:portal 에 모두 같은 값을 둔다 + * (HAL CLI link-portal 은 두 값을 다르게 만드는데, 포털은 양방향에 'service:hal' 하나를 쓰므로 같은 값이어야 중계가 된다 — 보고서의 결함 항목 참고). + */ +import { mkdirSync, writeFileSync, rmSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash, randomBytes } from 'node:crypto'; +import { openVault } from '../../lib/vault.mjs'; +import { ROOT, freePort, waitFor, makeClient, signedClient, tarGz, runNode, spawnNode, sleep } from './util.mjs'; +import { startFakeMail } from './fake-mail.mjs'; +import { startFakeWebmaker } from './fake-webmaker.mjs'; +import { startFakeArimail } from './fake-arimail.mjs'; + +const rnd = (n = 24) => randomBytes(n).toString('base64url'); + +/** Agent 하나 등록·실행. role: control|web|backup, external 이면 사이트 귀속 */ +export async function startAgent(env, { name, role = 'web', token = null, external = false, siteId = null, failHealth = false }) { + const dir = join(env.base, 'agents', name); mkdirSync(dir, { recursive: true }); + const roots = { sites: join(dir, 'sites'), srv: join(dir, 'srv'), nginx: join(dir, 'nginx'), backups: join(dir, 'backups'), logs: join(dir, 'logs'), acme: join(dir, 'acme'), le: join(dir, 'le') }; + for (const r of Object.values(roots)) mkdirSync(r, { recursive: true }); mkdirSync(join(roots.nginx, 'sites-available'), { recursive: true }); mkdirSync(join(roots.nginx, 'sites-enabled'), { recursive: true }); + const aenv = { ARIWEB_AGENT_DATA: join(dir, 'data'), ARIWEB_SITES_ROOT: roots.sites, ARIWEB_SRV_ROOT: roots.srv, ARIWEB_NGINX_DIR: roots.nginx, ARIWEB_BACKUP_ROOT: roots.backups, ARIWEB_LOG_DIR: roots.logs, ARIWEB_ACME_WEBROOT: roots.acme, ARIWEB_LE_DIR: roots.le, ARIWEB_WILDCARD_CERT: '', ARIWEB_PLATFORM_DOMAIN: 'localhost', ARIWEB_MAINT_PAGE: '', ARIWEB_AGENT_STUB_CMDS: '1', ARIWEB_AGENT_HEARTBEAT_MS: '3000', USER: process.env.USER || 'master' }; + let serverId = null; let regToken = token; + if (!external) { const r = await env.ops.post('/ops/api/servers', { name, role, location: 'e2e' }); if (r.status !== 200) throw new Error(`서버 생성 실패 ${r.status} ${r.text.slice(0, 200)}`); serverId = r.json.server.id; const t = await env.ops.post(`/ops/api/servers/${serverId}/token`); regToken = t.json.token; } + const out = runNode(['agent/index.mjs', 'register', '--hal', env.hal.url, '--token', regToken, '--role', role, '--label', name, ...(external ? ['--external', '--site', siteId] : [])], aenv); + const agentId = (out.match(/agent (ag_[a-z0-9]+)/) || [])[1]; + const proc = spawnNode(`agent:${name}`, ['agent/index.mjs', 'run'], aenv); + const a = { name, role, dir, roots, env: aenv, serverId, agentId, proc, external, siteId, restart() { a.proc.kill('SIGKILL'); a.proc = spawnNode(`agent:${name}`, ['agent/index.mjs', 'run'], aenv); return a.proc; } }; + env.agents.push(a); + if (!external) { await waitFor(async () => { const s = await env.ops.get(`/ops/api/servers/${serverId}`); return s.json?.server?.agentId ? s : null; }, { timeoutMs: 15000, intervalMs: 300 }); const ap = await env.ops.post(`/ops/api/servers/${serverId}/approve`); if (ap.status !== 200) throw new Error(`서버 승인 실패 ${ap.text.slice(0, 200)}`); } + else { await waitFor(async () => { const s = await env.ops.get('/ops/api/servers'); return s.status === 200 ? s : null; }, { timeoutMs: 5000 }); } + return a; +} + +/** 릴리스 artifact(작은 tar.gz)와 manifest 를 만든다. 모듈 디렉터리에 package.json(version) 이 있어야 Agent 가 실제 버전을 읽는다. */ +export function buildFakeRelease(base, version, { withWebmaker = false } = {}) { + const dist = join(base, 'dist', version); mkdirSync(dist, { recursive: true }); const artifacts = {}; + for (const m of ['hal', 'portal', 'agent', ...(withWebmaker ? ['webmaker', 'arimail'] : [])]) { + const src = join(base, 'dist-src', `${m}-${version}`); mkdirSync(join(src, m), { recursive: true }); writeFileSync(join(src, 'package.json'), JSON.stringify({ name: `ariweb-${m}`, version })); writeFileSync(join(src, m, 'index.mjs'), `// e2e fake ${m} ${version}\n`); + const file = `${m}-${version}.tar.gz`; tarGz(src, join(dist, file)); + artifacts[m] = { file, sha256: createHash('sha256').update(readFileSync(join(dist, file))).digest('hex'), size: statSync(join(dist, file)).size }; + } + const manifest = { version, mainCommit: 'e2e0000000000000000000000000000000000000', branch: 'e2e', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false } }; + writeFileSync(join(dist, 'manifest.json'), JSON.stringify(manifest, null, 1)); return { dist, manifest: join(dist, 'manifest.json'), artifacts }; +} + +export async function bootEnv({ log = () => {} } = {}) { + const base = join(ROOT, 'test', '.tmp', `e2e-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`); mkdirSync(base, { recursive: true }); + const [pHal, pPortal, pWm, pAm, pMail] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort()]); + const secrets = { service: rnd(32), sso: rnd(32), amSetup: 'st_' + rnd(20), amUsage: 'au_' + rnd(20), resend: 're_' + rnd(20), amSso: rnd(24) }; + const halData = join(base, 'hal'); const portalData = join(base, 'portal'); mkdirSync(halData, { recursive: true }); mkdirSync(portalData, { recursive: true }); + // 보관소: 서비스 키·SSO·발신 키 + const hv = openVault(halData); hv.set('service:portal', secrets.service, { kind: 'service-key' }); hv.set('service:hal', secrets.service, { kind: 'service-key' }); + const pv = openVault(portalData); pv.set('service:hal', secrets.service, { kind: 'service-key' }); pv.set('service:portal', secrets.service, { kind: 'service-key' }); pv.set('sso:webmaker', secrets.sso, { kind: 'shared-secret' }); pv.set('mailer:resend-key', secrets.resend, { kind: 'secret' }); + // 가짜 서비스 + const mail = await startFakeMail(pMail); const wm = await startFakeWebmaker(pWm, { secret: secrets.sso, workDir: join(base, 'wm-work') }); const am = await startFakeArimail(pAm, { setupToken: secrets.amSetup, usageKey: secrets.amUsage }); + writeFileSync(join(portalData, 'config.json'), JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), updatedBy: 'e2e', values: { mailer: 'resend', resendBase: mail.url, mailVerifier: 'verify@external.e2e.localhost', mailVerifierInsecureTls: true } })); + const env = { base, ports: { hal: pHal, portal: pPortal, wm: pWm, am: pAm, mail: pMail }, secrets, mail, wm, am, agents: [], procs: [], log }; + // ---- HAL + const halEnv = { ARIWEB_HAL_DATA: halData, PORT: String(pHal), HOST: '127.0.0.1', HAL_PUBLIC_URL: `http://127.0.0.1:${pHal}`, PORTAL_URL: `http://127.0.0.1:${pPortal}`, OPS_ALLOW_CIDRS: '127.0.0.1/32' }; + const tokOut = runNode(['hal/index.mjs', 'setup-token'], halEnv); const setupToken = (tokOut.match(/:\s*([A-Za-z0-9_-]{20,})/) || [])[1]; + const rel = buildFakeRelease(base, '0.1.1'); runNode(['hal/index.mjs', 'register-release', '--manifest', rel.manifest, '--approve', '--by', 'e2e'], halEnv); + const rel2 = buildFakeRelease(base, '0.1.2'); runNode(['hal/index.mjs', 'register-release', '--manifest', rel2.manifest, '--approve', '--by', 'e2e'], halEnv); + runNode(['hal/index.mjs', 'bootstrap-release', '--version', '0.1.1', '--commit', rel.manifest ? 'e2e0000000000000000000000000000000000000' : '', '--sha256', rel.artifacts.hal.sha256], halEnv); + env.hal = { url: `http://127.0.0.1:${pHal}`, data: halData, env: halEnv, proc: spawnNode('hal', ['hal/index.mjs'], halEnv), release: rel, release2: rel2, setupToken }; + env.hal.restart = async ({ takeover = false } = {}) => { env.hal.proc.kill('SIGTERM'); await env.hal.proc.waitExit(4000); if (!env.hal.proc.exited) env.hal.proc.kill('SIGKILL'); env.hal.proc = spawnNode('hal', ['hal/index.mjs', ...(takeover ? ['--takeover'] : [])], halEnv); return waitFor(async () => { const r = await fetch(env.hal.url + '/api/health').then((x) => x.json()).catch(() => null); return r && r.ok ? r : null; }, { timeoutMs: 15000, intervalMs: 300 }); }; + const h = await waitFor(async () => { const r = await fetch(env.hal.url + '/api/health').then((x) => x.json()).catch(() => null); return r && r.ok ? r : null; }, { timeoutMs: 20000, intervalMs: 300 }); + if (!h || h.timedOut) throw new Error('HAL 기동 실패: ' + env.hal.proc.lines.slice(-5).join(' | ')); + // 운영자 최초 설정·로그인 + env.ops = makeClient(env.hal.url); env.opsLogin = 'e2eops'; env.opsPassword = 'ops-password-' + rnd(8); + const su = await env.ops.post('/ops/api/setup', { setupToken, login: env.opsLogin, password: env.opsPassword, displayName: 'E2E 운영자' }); if (su.status !== 200) throw new Error('운영자 설정 실패 ' + su.text.slice(0, 200)); + env.loginOps = async () => { const r = await env.ops.post('/ops/api/login', { login: env.opsLogin, password: env.opsPassword }); if (r.status !== 200) throw new Error('운영자 로그인 실패 ' + r.text.slice(0, 200)); env.ops.setCsrf(r.json.csrf); return r; }; + await env.loginOps(); + /** 운영자 → 포털 내부 운영 API 중계(/ops/api/portal/* → 포털 /internal/ops/*) */ + env.relay = (method, path, body) => env.ops.req(method, '/ops/api/portal' + path, body); + /** 하네스가 포털 내부 API 를 HAL 키로 직접 서명 호출(중계 결함 우회가 아니라 비교·검증용) */ + env.portalSigned = signedClient(`http://127.0.0.1:${pPortal}`, 'hal', secrets.service, { 'X-Ops-Actor': 'e2e-ops' }); + env.halSigned = signedClient(env.hal.url, 'portal', secrets.service); + // ---- Agents: control(부트스트랩) → web → backup → web2 + env.servers = {}; + env.servers.control = await startAgent(env, { name: 'ctl-1', role: 'control' }); + env.servers.web = await startAgent(env, { name: 'web-1', role: 'web' }); + env.servers.backup = await startAgent(env, { name: 'backup-1', role: 'backup' }); + env.servers.web2 = await startAgent(env, { name: 'web-2', role: 'web' }); + // heartbeat 로 용량이 채워질 때까지(pickServer 가 여유 용량을 본다) + await waitFor(async () => { const s = await env.ops.get('/ops/api/servers'); const ok = s.json?.servers?.filter((x) => x.status === 'ready' && x.capacity?.diskTotalBytes > 0).length; return ok >= 4 ? s : null; }, { timeoutMs: 15000, intervalMs: 500 }); + // ---- 포털 + const portalEnv = { ARIWEB_PORTAL_DATA: portalData, PORT: String(pPortal), HOST: '127.0.0.1', PUBLIC_URL: `http://127.0.0.1:${pPortal}`, HAL_URL: env.hal.url, WEBMAKER_URL: wm.url, WEBMAKER_INTERNAL_URL: wm.url, ARIMAIL_URL: am.url, ARIMAIL_INTERNAL_URL: am.url, MAIL_FROM: 'no-reply@e2e.localhost', ARIWEB_AUTOSEED_CATALOG: '1' }; + env.portal = { url: `http://127.0.0.1:${pPortal}`, data: portalData, env: portalEnv, proc: spawnNode('portal', ['portal/index.mjs'], portalEnv) }; + env.portal.restart = async () => { env.portal.proc.kill('SIGTERM'); await env.portal.proc.waitExit(4000); if (!env.portal.proc.exited) env.portal.proc.kill('SIGKILL'); env.portal.proc = spawnNode('portal', ['portal/index.mjs'], portalEnv); return waitFor(async () => { const r = await fetch(env.portal.url + '/login').catch(() => null); return r && r.status === 200 ? r : null; }, { timeoutMs: 15000, intervalMs: 300 }); }; + const pr = await waitFor(async () => { const r = await fetch(env.portal.url + '/login').catch(() => null); return r && r.status === 200 ? r : null; }, { timeoutMs: 20000, intervalMs: 300 }); + if (!pr || pr.timedOut) throw new Error('포털 기동 실패: ' + env.portal.proc.lines.slice(-8).join(' | ')); + // AriMail 서버 등록(운영자 중계) — 비밀은 보관소로만 + const ms = await env.relay('POST', '/mail/servers', { name: 'e2e AriMail', baseUrl: am.url, internalUrl: am.url, setupUrl: am.url, mailHost: '127.0.0.1', isDefault: true, secrets: { 'setup-token': secrets.amSetup, 'usage-key': secrets.amUsage, 'sso-secret': secrets.amSso } }); + env.mailServerRegistered = ms.status === 200; env.mailServerResponse = { status: ms.status, body: ms.json }; + env.customer = () => makeClient(env.portal.url); + env.stop = async () => { for (const a of env.agents) a.proc.kill('SIGKILL'); env.portal.proc.kill('SIGTERM'); env.hal.proc.kill('SIGTERM'); await sleep(600); env.portal.proc.kill('SIGKILL'); env.hal.proc.kill('SIGKILL'); await Promise.all([mail.close(), wm.close(), am.close()]); if (!process.env.ARIWEB_E2E_KEEP) { try { rmSync(base, { recursive: true, force: true }); } catch (_) {} } }; + return env; +} diff --git a/test/e2e/fake-arimail.mjs b/test/e2e/fake-arimail.mjs new file mode 100644 index 0000000..fd8143b --- /dev/null +++ b/test/e2e/fake-arimail.mjs @@ -0,0 +1,64 @@ +/** + * test/e2e/fake-arimail.mjs — AriMail 의 포털 연동 면만 흉내 내는 HTTP 서버(설정 API·관리자 API·사용량 API·건강). + * + * portal/mail/arimail.mjs 어댑터가 부르는 끝점과 응답 모양을 따른다(test/mail.test.mjs 의 가짜 서버를 확장). + * - 설정 API(X-Setup-Token): /api/setup/domains(GET/POST), /api/setup/domains/:d(PATCH/DELETE), /api/setup/config(GET/PUT, ?domain=), /api/setup/plan, /api/setup/plan/run, /api/setup/dns/probe, /api/setup/check + * - 관리자 API(X-Mail-Scope: admin + mg_admin 쿠키): /api/login, /api/admin/users(GET/POST), /api/admin/users/:id(PATCH/DELETE), /api/admin/config(GET/PATCH), /api/admin/status, /api/admin/rates + * - 사용량 API(Bearer 키): /api/usage, /api/usage/:domain — 도메인별 보장량(LIMIT_*)과 저장·통수에서 경고 구간·제한 사유를 계산 + * - SMTP/IMAP/POP3 는 없다(메일 왕복은 로컬에서 검증할 수 없음 — 보고서에 그렇게 적는다). 하네스는 state.usage 로 사용량을 조작한다. + */ +import http from 'node:http'; + +export async function startFakeArimail(port, { setupToken, usageKey }) { + const state = { domains: new Map(), configs: new Map(), users: new Map(), services: new Map(), usage: new Map(), sessions: new Map(), calls: [] }; + const usersOf = (d) => { if (!state.users.has(d)) state.users.set(d, []); return state.users.get(d); }; + const level = (x, l) => (!l ? 0 : x / l >= 1 ? 100 : x / l >= 0.95 ? 95 : x / l >= 0.9 ? 90 : x / l >= 0.8 ? 80 : x / l >= 0.7 ? 70 : 0); + const report = (d) => { const u = state.usage.get(d) || {}; const cfg = state.configs.get(d) || {}; const users = usersOf(d); const lim = { accounts: Number(cfg.LIMIT_ACCOUNTS || 0), totalBytes: Number(cfg.LIMIT_TOTAL_BYTES || 0), monthlyMessages: Number(cfg.LIMIT_MONTHLY_MESSAGES || 0) }; const storage = u.storageBytes || 0; const month = (u.sent || 0) + (u.received || 0); const levels = { storage: level(storage, lim.totalBytes), monthly: level(month, lim.monthlyMessages), accounts: level(users.length, lim.accounts) }; const reasons = []; if (levels.storage === 100) reasons.push('storage_full'); if (levels.monthly === 100) reasons.push('monthly_limit'); if (levels.accounts === 100) reasons.push('accounts_full'); const e = state.domains.get(d) || {}; + return { domain: d, enabled: true, hosts: e.hosts || [`mail.${d}`], authMode: 'local', inbound: 'haraka', outbound: 'haraka', timezone: 'Asia/Seoul', ariweb: e.ariweb || null, operating: e.operating || 'active', accounts: { local: users.length, external: 0, total: users.length, limit: lim.accounts, percent: lim.accounts ? Math.round(users.length / lim.accounts * 100) : 0 }, storage: { bytes: storage, limit: lim.totalBytes, percent: lim.totalBytes ? Math.round(storage / lim.totalBytes * 100) : 0, largeFiles: { count: 0, bytes: 0, sent: 0 } }, messages: { total: u.messages || 0, inbound: u.received || 0, outbound: u.sent || 0, drafts: 0, failed: 0 }, traffic: { day: { sent: 0, received: 0, total: 0 }, week: { sent: 0, received: 0, total: 0 }, month: { sent: u.sent || 0, received: u.received || 0, total: month, sentBytes: 0, receivedBytes: 0 }, monthlyLimit: lim.monthlyMessages, monthlyPercent: lim.monthlyMessages ? Math.round(month / lim.monthlyMessages * 100) : 0 }, warnLevel: Math.max(levels.storage, levels.monthly, levels.accounts), warnLevels: levels, limitedReasons: reasons, monthReset: u.monthReset || null, countingRule: 'per_final_recipient', accountList: users.map((x) => ({ login: x.login, name: x.name, mailbox: `${x.login}@${d}`, role: x.role || 'user', source: 'local', status: x.status || 'active', messages: 0, bytes: x.bytes || 0, limit: x.quotaBytes || 0, percent: x.quotaBytes ? Math.round((x.bytes || 0) / x.quotaBytes * 100) : 0, lastLoginAt: null, sent: 0, received: 0 })), limits: { system: {}, admin: {} } }; }; + const srv = http.createServer((req, res) => { + let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + const url = new URL(req.url, 'http://x'); const p = url.pathname; const j = (code, o, h = {}) => { res.writeHead(code, { 'Content-Type': 'application/json', ...h }); res.end(JSON.stringify(o)); }; + let b = {}; try { b = body ? JSON.parse(body) : {}; } catch (_) {} + const host = String(req.headers['x-forwarded-host'] || req.headers.host || ''); const domainOfHost = [...state.domains.values()].find((d) => (d.hosts || []).some((h) => String(h).split('/')[0] === host))?.domain || null; + state.calls.push({ m: req.method, p, host }); + if (p === '/api/health') return j(200, { ok: true, version: '1.0.11-e2e-fake', domains: state.domains.size }); + // ---- 설정 API + if (p.startsWith('/api/setup/')) { + if (req.headers['x-setup-token'] !== setupToken) return j(401, { ok: false, error: 'setup_token_invalid' }); + const dq = url.searchParams.get('domain'); + if (p === '/api/setup/domains' && req.method === 'GET') return j(200, { ok: true, domains: [...state.domains.values()] }); + if (p === '/api/setup/domains' && req.method === 'POST') { const d = String(b.domain || '').toLowerCase(); if (!d) return j(422, { ok: false, error: 'domain_required' }); if (state.domains.has(d)) return j(409, { ok: false, error: 'domain_exists' }); const entry = { domain: d, enabled: true, hosts: b.hosts || [`mail.${d}`], mailbox: `${b.mailbox || 'admin'}@${d}`, auth: { mode: 'local' }, ariweb: b.ariweb || null, operating: b.operating || 'active', note: b.note || '' }; state.domains.set(d, entry); const pw = b.adminPassword || 'adminpasswd'; usersOf(d).push({ id: 'u_admin_' + d, login: b.mailbox || 'admin', name: '관리자', role: 'admin', password: pw, status: 'active' }); state.configs.set(d, {}); return j(201, { ok: true, domain: entry, initialAdmin: { login: b.mailbox || 'admin', password: pw } }); } + if (p.startsWith('/api/setup/domains/')) { const d = decodeURIComponent(p.split('/')[4] || ''); const e = state.domains.get(d); if (!e) return j(404, { ok: false, error: 'domain_not_found' }); if (req.method === 'PATCH') { Object.assign(e, b); return j(200, { ok: true, domain: e }); } if (req.method === 'DELETE') { state.domains.delete(d); state.users.delete(d); state.configs.delete(d); return j(200, { ok: true, removed: d }); } } + if (p === '/api/setup/config' && req.method === 'GET') { const d = dq || [...state.domains.keys()][0] || ''; const cfg = state.configs.get(d) || {}; return j(200, { ok: true, domain: d, values: Object.fromEntries(Object.entries(cfg).map(([k, v]) => [k, /SECRET|KEY|PASS/.test(k) ? '••••' : v])), meta: { host: `mail.${d}`, domain: d, dns: { records: [{ type: 'MX', name: d, value: `10 mail.${d}` }, { type: 'TXT', name: d, value: 'v=spf1 a mx -all' }, { type: 'TXT', name: `arimail._domainkey.${d}`, value: 'v=DKIM1; k=rsa; p=FAKEPUBLICKEY' }, { type: 'TXT', name: `_dmarc.${d}`, value: 'v=DMARC1; p=quarantine' }] }, inbound: 'haraka', outbound: 'haraka' } }); } + if (p === '/api/setup/config' && req.method === 'PUT') { const d = dq || ''; state.configs.set(d, { ...(state.configs.get(d) || {}), ...(b.values || {}) }); return j(200, { ok: true, saved: Object.keys(b.values || {}) }); } + if (p === '/api/setup/plan' && req.method === 'GET') return j(200, { ok: true, plan: { domain: dq, steps: [], records: [] } }); + if (p === '/api/setup/plan/run') return j(200, { ok: true, log: [] }); + if (p === '/api/setup/dns/probe') return j(200, { ok: true, probe: { mx: [], spf: null, dkim: null, dmarc: null } }); + if (p === '/api/setup/check') return j(200, { ok: true, outboundStatus: { mode: 'haraka', provider: 'ok', checkedAt: new Date().toISOString() } }); + return j(404, { ok: false, error: 'not_found' }); + } + // ---- 사용량 API + if (p.startsWith('/api/usage')) { if ((req.headers.authorization || '') !== 'Bearer ' + usageKey) return j(401, { ok: false, error: 'unauthenticated' }); const d = decodeURIComponent(p.split('/')[3] || ''); if (d) { if (!state.domains.has(d)) return j(404, { ok: false, error: 'domain_not_found' }); return j(200, { ok: true, version: '1.0.11-e2e-fake', generatedAt: new Date().toISOString(), ...report(d) }); } return j(200, { ok: true, version: '1.0.11-e2e-fake', generatedAt: new Date().toISOString(), primary: [...state.domains.keys()][0] || null, totals: { domains: state.domains.size }, domains: [...state.domains.keys()].map(report) }); } + // ---- 로그인·관리자 API(도메인은 Host 로) + const d = domainOfHost; const admin = req.headers['x-mail-scope'] === 'admin'; + if (p === '/api/login' && req.method === 'POST') { if (!d) return j(404, { ok: false, error: 'no_service_for_host' }); const u = usersOf(d).find((x) => x.login === b.login && x.password === b.password); if (!u) return j(401, { ok: false, error: 'invalid_credentials' }); if (admin && u.role !== 'admin') return j(403, { ok: false, error: 'admin_only' }); const tok = 'tok_' + Math.random().toString(36).slice(2); state.sessions.set(tok, { d, login: u.login, admin }); return j(200, { ok: true, user: { login: u.login, role: u.role } }, { 'Set-Cookie': `${admin ? 'mg_admin' : 'mg_session'}=${tok}; Path=/; HttpOnly` }); } + const cookie = String(req.headers.cookie || ''); const tok = (cookie.match(/mg_admin=([^;]+)/) || [])[1]; const sess = tok && state.sessions.get(tok); + if (p.startsWith('/api/admin/')) { + if (!admin || !sess || !sess.admin || sess.d !== d) return j(403, { ok: false, error: 'admin_login_required' }); + const cfg = state.configs.get(d) || {}; const list = usersOf(d); + if (p === '/api/admin/users' && req.method === 'GET') return j(200, { ok: true, users: list.map((u) => ({ id: u.id, login: u.login, name: u.name, mailbox: `${u.login}@${d}`, role: u.role || 'user', status: u.status || 'active', quotaBytes: u.quotaBytes || 0, bytes: u.bytes || 0, services: u.services || { pop: true, imap: true } })), domain: d, accounts: { used: list.length, limit: Number(cfg.LIMIT_ACCOUNTS || 0) }, policy: { minLength: 6 }, services: state.services.get(d) || { pop: true, imap: true } }); + if (p === '/api/admin/users' && req.method === 'POST') { if (list.some((u) => u.login === b.login)) return j(409, { ok: false, error: 'login_taken' }); if (Number(cfg.LIMIT_ACCOUNTS || 0) && list.length >= Number(cfg.LIMIT_ACCOUNTS)) return j(409, { ok: false, error: 'account_limit_reached' }); if (String(b.password || '').length < 6) return j(422, { ok: false, error: 'weak_password' }); const id = 'u_' + Math.random().toString(36).slice(2, 10); list.push({ id, login: b.login, name: b.name || '', role: b.role || 'user', password: b.password, status: b.status || 'active', quotaBytes: b.quotaBytes || 0, services: b.services }); return j(200, { ok: true, id }); } + if (p.startsWith('/api/admin/users/')) { const id = decodeURIComponent(p.split('/')[4]); const u = list.find((x) => x.id === id); if (!u) return j(404, { ok: false, error: 'not_found' }); if (req.method === 'PATCH') { Object.assign(u, b); return j(200, { ok: true }); } if (req.method === 'DELETE') { list.splice(list.indexOf(u), 1); return j(200, { ok: true }); } } + if (p === '/api/admin/config' && req.method === 'GET') return j(200, { ok: true, services: state.services.get(d) || { pop: true, imap: true }, largeFiles: { enabled: false, maxBytes: 0, maxDays: 0, maxDownloads: 0 }, systemLargeFiles: { bytes: 1024 ** 3, days: 30, downloads: 20 } }); + if (p === '/api/admin/config' && req.method === 'PATCH') { if (b.services) state.services.set(d, { ...(state.services.get(d) || { pop: true, imap: true }), ...b.services }); return j(200, { ok: true, services: state.services.get(d) || { pop: true, imap: true } }); } + if (p === '/api/admin/status') return j(200, { ok: true, host: `mail.${d}`, tls: true, ports: { pop3s: 995, pop3: 110, imaps: 993, imap: 143, smtps: 465, submission: 587 }, services: state.services.get(d) || { pop: true, imap: true }, mail: { inbound: 'haraka', outbound: 'haraka', status: { provider: 'ok' } } }); + if (p === '/api/admin/rates') return j(200, { ok: true, rates: { deliveryRate: 5, submissionRate: 10, buckets: [], submissions: [] }, queue: [], abuse: [] }); + return j(404, { ok: false, error: 'not_found' }); + } + if (p === '/api/mail') return j(200, { ok: true, messages: [] }); + j(404, { ok: false, error: 'not_found' }); + }); + }); + await new Promise((r) => srv.listen(port, '127.0.0.1', r)); + return { url: `http://127.0.0.1:${port}`, state, close: () => new Promise((r) => srv.close(r)) }; +} diff --git a/test/e2e/fake-mail.mjs b/test/e2e/fake-mail.mjs new file mode 100644 index 0000000..5130080 --- /dev/null +++ b/test/e2e/fake-mail.mjs @@ -0,0 +1,19 @@ +/** + * test/e2e/fake-mail.mjs — Resend 호환 발신 API 흉내(포털 mailer 'resend' 모드 포획). + * 포털이 보내는 이메일(가입 확인·초대·필수 알림)을 메모리에 모아 하네스가 링크·토큰을 꺼내 쓴다. 실제 발송은 없다. + * POST /emails { from, to[], subject, text, html } → { id } + */ +import http from 'node:http'; + +export async function startFakeMail(port) { + const mails = []; + const srv = http.createServer((req, res) => { + let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + const j = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); }; + if (req.method === 'POST' && req.url === '/emails') { if (!/^Bearer /.test(req.headers.authorization || '')) return j(401, { name: 'unauthenticated' }); let m = {}; try { m = JSON.parse(body); } catch (_) {} const id = 'mail_' + (mails.length + 1); mails.push({ id, at: new Date().toISOString(), to: [].concat(m.to || []), subject: m.subject || '', text: m.text || '', html: m.html || '' }); return j(200, { id }); } + j(404, { name: 'not_found' }); + }); + }); + await new Promise((r) => srv.listen(port, '127.0.0.1', r)); + return { url: `http://127.0.0.1:${port}`, mails, /** 받는 사람·제목 조건으로 마지막 메일 찾기 */ last: (to, re = null) => [...mails].reverse().find((m) => (!to || m.to.includes(to)) && (!re || re.test(m.subject + '\n' + m.text))) || null, close: () => new Promise((r) => srv.close(r)) }; +} diff --git a/test/e2e/fake-webmaker.mjs b/test/e2e/fake-webmaker.mjs new file mode 100644 index 0000000..a5544ed --- /dev/null +++ b/test/e2e/fake-webmaker.mjs @@ -0,0 +1,62 @@ +/** + * test/e2e/fake-webmaker.mjs — Webmaker 의 포털 연동 면(/api/internal/ariweb/*, /api/auth/sso)만 흉내 내는 HTTP 서버. + * + * 응답 모양은 modules/webmaker/server.js 의 handleAriwebInternal 과 portal/webmaker/adapter.mjs 가 기대하는 것을 따른다. + * - 서명 검증: lib/serviceauth(키 id 'portal', 비밀 = 포털 보관소 'sso:webmaker') + * - 프로젝트는 메모리. 저장본(versionList)은 시험 전용 끝점 POST /_test/projects/:id/version 으로 추가한다(빌더에서 저장한 것을 흉내). + * - export 는 index.html(표식 marker-<프로젝트>-v 포함)을 담은 tar.gz 스트림 + X-Ariweb-Version/X-Ariweb-Commit 헤더. + * - /api/auth/sso?ticket= : HMAC 검증 뒤 302(세션 쿠키 흉내). 1회용 nonce. + */ +import http from 'node:http'; +import { mkdirSync, writeFileSync, createReadStream, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { verifyRequest } from '../../lib/serviceauth.mjs'; +import { tarGz } from './util.mjs'; + +export async function startFakeWebmaker(port, { secret, workDir }) { + const projects = new Map(); let seq = 0; const calls = []; const usedNonces = new Set(); const credits = new Map(); + mkdirSync(workDir, { recursive: true }); + const summary = (p) => ({ id: p.id, title: p.title, ownerId: p.ownerId, status: p.status, mode: p.mode, stage: p.stage, rev: p.rev, createdAt: p.createdAt, updatedAt: p.updatedAt, lastChange: p.versionList.at(-1) ? { no: p.versionList.at(-1).no, kind: p.versionList.at(-1).kind, at: p.versionList.at(-1).at, message: p.versionList.at(-1).message } : null, versions: p.versionList.length, link: p.link, hasContent: p.versionList.length > 0 }); + const full = (p) => ({ ...summary(p), versionList: p.versionList.map((v) => ({ no: v.no, kind: v.kind, message: v.message, at: v.at, commit: v.commit, files: 1 })), git: { provider: 'gitea', enabled: true, remote: 'webmaker/sites', mainBranch: `sites/${p.id}/main`, mainUrl: `http://127.0.0.1:${port}/gitea/webmaker/sites/src/branch/sites/${p.id}/main` }, liveUrl: null, deployState: null, domains: [] }); + const srv = http.createServer((req, res) => { + let raw = ''; req.on('data', (c) => (raw += c)); req.on('end', async () => { + const url = new URL(req.url, 'http://x'); const p = url.pathname; const j = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); }; + calls.push({ m: req.method, p }); + // 시험 전용: 저장본 추가(빌더에서 저장한 것을 흉내) — 서명 없음(127.0.0.1 전용) + if (p.startsWith('/_test/projects/') && req.method === 'POST') { const id = p.split('/')[3]; const pr = projects.get(id); if (!pr) return j(404, { error: 'no project' }); const no = pr.versionList.length + 1; pr.versionList.push({ no, kind: no === 1 ? 'create' : 'update', message: `저장본 ${no}`, at: new Date().toISOString(), commit: `c${no}-${id.slice(0, 6)}` }); pr.rev++; pr.updatedAt = new Date().toISOString(); return j(200, { ok: true, no }); } + if (p === '/_test/state') return j(200, { projects: [...projects.values()].map(full), calls: calls.length }); + if (p === '/api/auth/sso') { + const t = String(url.searchParams.get('ticket') || ''); const [payload, sig] = t.split('.'); if (!payload || !sig) return j(400, { error: 'bad_ticket' }); + const want = createHmac('sha256', secret).update(payload).digest('base64url'); const a = Buffer.from(want), b = Buffer.from(sig); if (a.length !== b.length || !timingSafeEqual(a, b)) return j(401, { error: 'bad_signature' }); + let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return j(400, { error: 'bad_payload' }); } + if (d.exp < Date.now()) return j(401, { error: 'expired' }); if (usedNonces.has(d.nonce)) return j(401, { error: 'ticket_used' }); usedNonces.add(d.nonce); + res.writeHead(302, { Location: `/?project=${encodeURIComponent(d.projectId || '')}&site=${encodeURIComponent(d.siteId || '')}`, 'Set-Cookie': `wh_session=fake-${d.login}; Path=/; HttpOnly` }); return res.end(); + } + if (!p.startsWith('/api/internal/ariweb/')) return j(404, { error: 'not_found' }); + const v = verifyRequest({ method: req.method, path: p + url.search, headers: req.headers, rawBody: raw }, (id) => (id === 'portal' ? secret : null)); if (!v.ok) return j(401, { error: 'service_auth_failed', reason: v.reason }); + let b = {}; try { b = raw ? JSON.parse(raw) : {}; } catch (_) {} + const [kind, id, action] = p.slice('/api/internal/ariweb/'.length).split('/').filter(Boolean).map(decodeURIComponent); + if (kind === 'health') return j(200, { ok: true, version: '1.7.3-fake', product: 'AI Webmaker(fake)', portalUrl: null }); + if (kind === 'credits' && id) { const c = credits.get(id) || { balance: 12000, used: 3000, granted: 15000 }; return j(200, { ok: true, credit: { userId: id, ...c, updatedAt: new Date().toISOString(), entries: 2 }, unit: '원' }); } + if (kind !== 'projects') return j(404, { error: 'not_found' }); + if (!id && req.method === 'GET') { const owner = String(url.searchParams.get('owner') || '').toLowerCase(); return j(200, { ok: true, projects: [...projects.values()].filter((x) => x.ownerId === owner).map(summary) }); } + if (!id && req.method === 'POST') { const pid = (++seq).toString(16).padStart(32, '0'); const pr = { id: pid, title: String(b.title || ''), ownerId: String(b.owner || '').toLowerCase(), status: 'idle', mode: b.mode || 'quick', stage: b.mode === 'ai' ? 'input' : null, rev: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), versionList: [], link: b.link && b.link.siteId ? { ...b.link, linkedAt: new Date().toISOString() } : null }; projects.set(pid, pr); return j(200, { ok: true, project: summary(pr) }); } + const pr = projects.get(id); if (!pr) return j(404, { error: '프로젝트가 없습니다' }); + if (!action && req.method === 'GET') return j(200, { ok: true, project: full(pr) }); + if (action === 'link' && req.method === 'POST') { pr.link = { ...b, linkedAt: new Date().toISOString() }; return j(200, { ok: true, link: pr.link }); } + if (action === 'link' && req.method === 'DELETE') { const had = !!pr.link; pr.link = null; return j(200, { ok: true, removed: had }); } + if (action === 'source' && req.method === 'GET') return j(200, { ok: true, source: { ...full(pr).git, repoBytes: 2048, siteBytes: 4096, uploadBytes: 0, lfsBytes: 0, lfsSupported: false, changes: pr.versionList.map((x) => ({ ...x, pushed: true, branch: `sites/${pr.id}/changes/${String(x.no).padStart(4, '0')}-${x.kind}`, url: null })), remoteStatus: { ok: true } } }); + if (action === 'export' && req.method === 'GET') { + const vno = url.searchParams.get('version'); const ch = vno ? pr.versionList.find((x) => String(x.no) === String(vno)) : pr.versionList.at(-1); if (vno && !ch) return j(404, { error: '해당 저장본이 없습니다' }); + const dir = join(workDir, `export-${pr.id}-${Date.now()}`); mkdirSync(dir, { recursive: true }); const n = ch ? ch.no : 0; + writeFileSync(join(dir, 'index.html'), `${pr.title}

${pr.title}

marker-${pr.id.slice(0, 8)}-v${n}

`); writeFileSync(join(dir, 'ariweb.json'), JSON.stringify({ runtime: 'static' })); + const file = join(workDir, `export-${pr.id}-${Date.now()}.tar.gz`); tarGz(dir, file); rmSync(dir, { recursive: true, force: true }); + res.writeHead(200, { 'Content-Type': 'application/gzip', 'X-Ariweb-Version': vno || 'current', 'X-Ariweb-Commit': ch ? ch.commit : '' }); createReadStream(file).pipe(res); return; + } + j(404, { error: 'not_found' }); + }); + }); + await new Promise((r) => srv.listen(port, '127.0.0.1', r)); + return { url: `http://127.0.0.1:${port}`, projects, calls, addVersion: async (id) => { const r = await fetch(`http://127.0.0.1:${port}/_test/projects/${id}/version`, { method: 'POST' }); return r.json(); }, close: () => new Promise((r) => srv.close(r)) }; +} diff --git a/test/e2e/flows/10-identity.mjs b/test/e2e/flows/10-identity.mjs new file mode 100644 index 0000000..d6725a8 --- /dev/null +++ b/test/e2e/flows/10-identity.mjs @@ -0,0 +1,42 @@ +/** 흐름 1·2: 가입 → 확인 메일 → 이메일 확인 → 개인 그룹 → 팀 그룹·초대·수락. 이후 흐름이 쓰는 고객 클라이언트(state.alice / state.bob)를 만든다. */ +import { steps } from '../report.mjs'; +import { waitFor } from '../util.mjs'; +export const BUDGET_MS = 40000; + +/** 가입 + 이메일 확인 + CSRF 까지 끝낸 고객 클라이언트 */ +export async function signupVerified(env, email, name) { + const c = env.customer(); + const r = await c.post('/api/auth/signup', { email, displayName: name, password: 'pw-' + email.split('@')[0] + '-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 200) throw new Error(`가입 실패 ${r.status} ${r.text.slice(0, 200)}`); + const cs0 = await c.get('/api/auth/csrf'); c.setCsrf(cs0.json.csrf); // 가입 직후 세션의 CSRF(변경 API 에 필요) + const m = await waitFor(() => env.mail.last(email, /token=/), { timeoutMs: 8000, intervalMs: 200 }); const token = m && m.text ? (m.text.match(/token=([A-Za-z0-9_-]+)/) || [])[1] : null; if (!token) throw new Error('확인 메일 토큰 없음'); + const v = await c.post('/api/auth/verify-email', { token }); if (v.status !== 200) throw new Error(`이메일 확인 실패 ${v.text.slice(0, 200)}`); + const cs = await c.get('/api/auth/csrf'); c.setCsrf(cs.json.csrf); c.email = email; return c; +} +export async function run({ env, report, state }) { + const s = steps(); + try { + const email = 'alice@e2e.localhost'; + const alice = await s.run('가입(이메일·표시 이름·비밀번호·필수 동의) → 세션 발급', async () => { const c = env.customer(); const r = await c.post('/api/auth/signup', { email, displayName: '앨리스', password: 'pw-alice-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return { status: r.status, hasSession: !!c.cookie('aw_session') }; }) && env.customer(); + const r0 = await alice.post('/api/auth/login', { email, password: 'pw-alice-e2e-1' }); const cs0 = await alice.get('/api/auth/csrf'); alice.setCsrf(cs0.json.csrf); alice.email = email; + await s.run('확인 전 유료·운영 기능 차단(그룹 생성 403 email_unverified)', async () => { const r = await alice.post('/api/groups', { name: '팀' }); if (r.status !== 403 || r.json.error !== 'email_unverified') throw new Error(`${r.status} ${r.json?.error}`); return r.json.error; }); + const token = await s.run('확인 메일 수신(발신 포획)에서 토큰 추출', async () => { const m = await waitFor(() => env.mail.last(email, /token=/), { timeoutMs: 8000, intervalMs: 200 }); const t = m && m.text ? (m.text.match(/token=([A-Za-z0-9_-]+)/) || [])[1] : null; if (!t) throw new Error('메일 없음'); return { subject: m.subject }; }) && (env.mail.last(email, /token=/).text.match(/token=([A-Za-z0-9_-]+)/) || [])[1]; + await s.run('이메일 확인(1회용) → 재사용 거부', async () => { const a = await alice.post('/api/auth/verify-email', { token }); const b = await alice.post('/api/auth/verify-email', { token }); if (a.status !== 200 || b.json?.error !== 'token_used') throw new Error(`${a.status}/${b.json?.error}`); return { first: a.status, second: b.json.error }; }); + const me = await s.run('내 정보: 이메일 확인됨 · 개인 그룹 · owner · 별칭 user-01', async () => { const r = await alice.get('/api/auth/me'); const g = await alice.get('/api/group'); if (!r.json.user.email_verified_at || r.json.group.kind !== 'personal') throw new Error(JSON.stringify(r.json).slice(0, 200)); return { verified: true, groupKind: r.json.group.kind, role: r.json.role, alias: g.json.members?.[0]?.alias }; }); + await s.run('중복 가입은 존재를 드러내지 않는 안내(409 signup_unavailable)', async () => { const r = await env.customer().post('/api/auth/signup', { email: 'ALICE@e2e.localhost', displayName: 'x', password: 'pw-dup-e2e-1', consents: { terms: true, privacy: true } }); if (r.status !== 409) throw new Error(String(r.status)); return r.json.error; }); + await s.run('로그인 기기 목록 · 다른 세션 종료', async () => { const other = env.customer(); await other.post('/api/auth/login', { email, password: 'pw-alice-e2e-1' }); const l = await alice.get('/api/account/sessions'); const n = l.json.sessions.length; const rv = await alice.post('/api/account/sessions/revoke-others'); const me2 = await other.get('/api/auth/me'); if (!(n >= 2) || me2.json.user) throw new Error('세션 종료 안 됨'); return { sessionsBefore: n, revoked: rv.json.revoked }; }); + state.alice = alice; state.aliceEmail = email; + report.flow(1, 'verified_local', { steps: s.list, note: '가입→확인 메일(포획)→1회용 토큰 확인→개인 그룹·별칭→중복 가입 안내→기기 목록·세션 종료', evidence: { me } }); + } catch (e) { report.flow(1, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '가입·이메일 확인 흐름 실패', file: 'portal/auth/api.mjs', expected: '가입→확인→개인 그룹', actual: e.message, repro: '10-identity.mjs 흐름 1', flows: [1] }); return; } + const s2 = steps(); + try { + const alice = state.alice; + const teamId = await s2.run('팀 그룹 만들기 → 현재 그룹 전환', async () => { const r = await alice.post('/api/groups', { name: 'E2E 팀' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const me = await alice.get('/api/auth/me'); if (me.json.group.id !== r.json.groupId) throw new Error('전환 안 됨'); return r.json.groupId; }); + const bob = await signupVerified(env, 'bob@e2e.localhost', '밥'); + const link = await s2.run('이메일 초대(7일) → 초대 메일 링크', async () => { const r = await alice.post('/api/group/invites', { email: 'bob@e2e.localhost', role: 'member' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const m = await waitFor(() => env.mail.last('bob@e2e.localhost', /invite\//), { timeoutMs: 8000, intervalMs: 200 }); const t = m && m.text ? (m.text.match(/invite\/([A-Za-z0-9_-]+)/) || [])[1] : null; if (!t) throw new Error('초대 메일 없음'); return t; }); + await s2.run('초대 수락(1회용) → 별칭 user-02 · 역할 member', async () => { const r = await bob.post('/api/invites/accept', { token: link }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const g = await bob.get('/api/group'); const again = await bob.post('/api/invites/accept', { token: link }); if (g.json.group.id !== teamId || again.status === 200) throw new Error('수락 상태 이상'); return { alias: g.json.me.alias, role: g.json.me.role, reuse: again.json?.error }; }); + await s2.run('권한 없는 구성원의 직접 API 접근 → 403 + 필요한 역할', async () => { const r = await bob.patch('/api/group', { name: 'x' }); if (r.status !== 403) throw new Error(String(r.status)); return r.json.detail; }); + await s2.run('그룹 전환 격리: 개인 그룹으로 바꾸면 팀 데이터가 보이지 않음', async () => { const personal = (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal'); const a = await bob.get('/api/dashboard'); await bob.post('/api/groups/switch', { groupId: personal.id }); const b = await bob.get('/api/dashboard'); await bob.post('/api/groups/switch', { groupId: teamId }); return { team: a.json.summary?.sites, personal: b.json.summary?.sites }; }); + state.bob = bob; state.teamId = teamId; await alice.post('/api/groups/switch', { groupId: teamId }); + report.flow(2, 'verified_local', { steps: s2.list, note: '팀 그룹 생성·초대 메일·1회용 수락·별칭 user-02·역할 거부·그룹 전환 격리' }); + } catch (e) { report.flow(2, 'failed', { steps: s2.list, note: e.message }); report.bug({ title: '그룹·초대 흐름 실패', file: 'portal/groups/api.mjs', expected: '팀 그룹·초대·수락', actual: e.message, repro: '10-identity.mjs 흐름 2', flows: [2] }); } +} diff --git a/test/e2e/flows/20-commerce.mjs b/test/e2e/flows/20-commerce.mjs new file mode 100644 index 0000000..3579007 --- /dev/null +++ b/test/e2e/flows/20-commerce.mjs @@ -0,0 +1,33 @@ +/** 흐름 3·24: 상품 목록 → 주문(멱등) → 운영자 입금 확인·승인(HAL 중계) → 권리 활성화 → 고객 알림·감사·운영자 화면 일치. */ +import { steps } from '../report.mjs'; +export const BUDGET_MS = 40000; + +/** 주문을 넣고 운영자 중계로 입금 확인·승인까지 끝낸다(다른 흐름이 재사용). */ +export async function orderAndApprove(env, client, { productId = 'team', items = [], key }) { + const prods = await client.get('/api/commerce/products'); const pv = prods.json.products.find((p) => p.productId === productId || p.product_id === productId || p.id === productId); if (!pv) throw new Error(`상품 없음: ${productId} (${prods.status})`); + const o = await client.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: key, items }); if (o.status !== 200) throw new Error(`주문 실패 ${o.status} ${o.text.slice(0, 200)}`); + const paid = await env.relay('POST', `/commerce/orders/${o.json.order.id}/paid`, { ref: 'e2e 입금' }); if (paid.status !== 200) throw new Error(`입금 확인 실패 ${paid.status} ${paid.text.slice(0, 200)}`); + const ap = await env.relay('POST', `/commerce/orders/${o.json.order.id}/approve`, {}); if (ap.status !== 200) throw new Error(`승인 실패 ${ap.status} ${ap.text.slice(0, 200)}`); + return { order: o.json.order, pv, approve: ap.json }; +} +export async function run({ env, report, state }) { + const s = steps(); const alice = state.alice; if (!alice) { report.flow(3, 'partial', { note: '고객 세션 없음', blockedBy: 1 }); return; } + try { + const pv = await s.run('상품 목록(공개 버전만·가격·한도·미확정 정책 표시)', async () => { const r = await alice.get('/api/commerce/products'); if (r.status !== 200 || !r.json.products.length) throw new Error(r.text.slice(0, 200)); const team = r.json.products.find((p) => p.productId === 'team'); return { count: r.json.products.length, team: { id: team.id, price: team.price, sites: team.limits?.sites }, deferred: team.deferred?.length, paymentMethods: r.json.paymentMethods?.map((m) => m.id) }; }) && (await alice.get('/api/commerce/products')).json.products.find((p) => p.productId === 'team'); + const order = await s.run('주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음', async () => { const a = await alice.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'e2e-order-1', items: [{ optionId: 'standalone_mail', qty: 1 }] }); if (a.status !== 200) throw new Error(a.text.slice(0, 300)); const b = await alice.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'e2e-order-1' }); if (b.json.created !== false) throw new Error('멱등 실패'); return { orderNo: a.json.order.order_no, status: a.json.order.status, total: a.json.order.total, dueAt: a.json.order.due_at, hasInstructions: !!a.json.order.payment_instructions }; }) && (await alice.get('/api/commerce/orders')).json.orders[0]; + await s.run('입금 확인 전 승인 거부(409 payment_not_confirmed)', async () => { const r = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); if (r.status !== 409) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); return r.json.error; }); + await s.run('운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시', async () => { const r = await env.relay('POST', `/commerce/orders/${order.id}/paid`, { ref: 'e2e 입금 확인' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const l = await env.relay('GET', '/commerce/orders?status=awaiting_approval'); const o = l.json.orders.find((x) => x.id === order.id); return { status: r.json.order.status, delta: o?.entitlementDelta?.map((d) => d.resource) }; }); + const ap = await s.run('운영자 승인 → 권리 활성화(재승인은 중복 없음)', async () => { const a = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); if (a.status !== 200) throw new Error(a.text.slice(0, 200)); const b = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); return { granted: a.json.granted, already: b.json.already }; }); + const bill = await s.run('고객 결제 화면: 활성 상품·권리·다음 청구일', async () => { const r = await alice.get('/api/commerce/billing'); const sites = r.json.entitlements?.find((e) => e.resource === 'sites'); if (!sites || sites.purchased < 1) throw new Error(JSON.stringify(r.json).slice(0, 300)); return { sites: sites.purchased, subs: r.json.subscriptions?.length, nextBilling: r.json.subscriptions?.[0]?.next_billing_at }; }); + state.order = order; state.teamProduct = pv; + report.flow(3, 'verified_local', { steps: s.list, note: '상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면', evidence: { ap, bill } }); + } catch (e) { report.flow(3, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '주문·승인·권리 활성화 흐름 실패', file: 'portal/commerce/api.mjs', route: '/api/commerce/orders, /internal/ops/commerce/orders/:id/{paid,approve}', expected: '승인 뒤 권리 활성화', actual: e.message, repro: '20-commerce.mjs', flows: [3] }); return; } + const s2 = steps(); + try { + const alice = state.alice; const order = state.order; + const n = await s2.run('고객 알림 센터에 승인 알림', async () => { const r = await alice.get('/api/notifications'); const x = (r.json.notifications || r.json.items || []).find((y) => /승인/.test(y.title) && (y.subject_id === order.id || /주문/.test(y.title))); if (!x) throw new Error('알림 없음: ' + JSON.stringify(r.json).slice(0, 200)); return { title: x.title, category: x.category, link: x.link }; }); + const a = await s2.run('감사 기록: order.approve 는 삭제 불가(immutable)', async () => { const r = await alice.get('/api/audit?action=order.approve'); const x = (r.json.items || [])[0]; if (!x || !x.immutable) throw new Error(JSON.stringify(r.json).slice(0, 200)); return { action: x.action, immutable: x.immutable, actor: x.actor_type }; }); + const same = await s2.run('운영자 화면과 고객 주문 화면의 상태 일치', async () => { const o = await env.relay('GET', `/commerce/orders/${order.id}`); const c = await alice.get(`/api/commerce/orders/${order.id}`); if (o.json.order.status !== c.json.order.status) throw new Error(`${o.json.order.status} vs ${c.json.order.status}`); return { status: c.json.order.status, approvedAt: c.json.order.approved_at }; }); + report.flow(24, 'verified_local', { steps: s2.list, note: '승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준)', evidence: { n, a, same } }); + } catch (e) { report.flow(24, 'partial', { steps: s2.list, note: e.message }); } +} diff --git a/test/e2e/flows/30-sites.mjs b/test/e2e/flows/30-sites.mjs new file mode 100644 index 0000000..e8d0952 --- /dev/null +++ b/test/e2e/flows/30-sites.mjs @@ -0,0 +1,85 @@ +/** 흐름 4·5·6·7·8·9·23·27: 초안 사이트 → Webmaker SSO·저장본 → 관리형 배포(실제 HAL·Agent) → 도메인 연결 → 외부 확인 → 통계 → 실패 작업 재시도·되돌리기 → 마지막 사이트 수 동시 요청. */ +import { steps } from '../report.mjs'; +import { waitFor, sleep } from '../util.mjs'; +import { signupVerified } from './10-identity.mjs'; +import { orderAndApprove } from './20-commerce.mjs'; +export const BUDGET_MS = 150000; + +/** 포털 작업이 끝날 때까지(최대 ms) 사이트 작업 조회 */ +export async function waitSiteJob(client, siteId, jobId, ms = 60000) { return waitFor(async () => { const r = await client.get(`/api/sites/${siteId}/jobs/${jobId}`); const j = r.json?.job || r.json; return j && ['done', 'failed', 'cancelled', 'reverted', 'needs_user', 'awaiting_external'].includes(j.status) ? j : null; }, { timeoutMs: ms, intervalMs: 1000, label: 'site job' }); } +/** 도메인 연결 작업 취소(결함: 외부 대기 중 5초마다 HAL domain.probe 재제출 — 뒤 흐름의 HAL 큐 보호용) */ +export async function cancelDomainJob(client, domainId) { const x = await client.get(`/api/domains/${domainId}`); const jid = x.json?.job?.id; if (!jid) return null; const r = await client.post(`/api/domains/${domainId}/jobs/${jid}/cancel`); return r.status; } +export async function createSiteWithVersion(env, client, name, { deployMode = 'managed' } = {}) { + const r = await client.post('/api/sites', { name, purpose: 'e2e', projectMode: 'new', buildMode: 'manual', deployMode }); if (r.status !== 200) throw new Error(`사이트 생성 실패 ${r.status} ${r.text.slice(0, 200)}`); + const site = r.json.site; await env.wm.addVersion(site.webmaker_project_id); return site; +} +export async function run({ env, report, state, log }) { + const alice = state.alice; if (!alice) { for (const n of [4, 5, 6, 7, 8, 9, 23, 27]) report.flow(n, 'partial', { note: '고객 세션 없음', blockedBy: 1 }); return; } + let site = null; + // ---- 4 초안 사이트 + { const s = steps(); try { + site = await s.run('사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내', async () => { const r = await alice.post('/api/sites', { name: 'E2E 사이트', purpose: '검증', projectMode: 'new', buildMode: 'manual', deployMode: 'managed', lang: 'ko', timezone: 'Asia/Seoul' }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); if (r.json.site.lifecycle !== 'draft' || r.json.site.site_count_state !== 'none') throw new Error(JSON.stringify(r.json.site).slice(0, 200)); state._created = r.json.site; return { id: r.json.site.id, lifecycle: r.json.site.lifecycle, projectId: r.json.site.webmaker_project_id, nextSteps: r.json.nextSteps?.map((x) => x.id) }; }) && state._created; + await s.run('사이트 목록 필터(초안)·사용량 요약에 예약 0', async () => { const l = await alice.get('/api/sites?filter=draft'); const u = await alice.get('/api/usage'); const sites = u.json.resources?.find((x) => x.resource === 'sites'); return { drafts: l.json.sites.length, sitesReserved: sites?.reserved, sitesAllocated: sites?.allocated }; }); + state.site = site; report.flow(4, 'verified_local', { steps: s.list, note: '초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터' }); + } catch (e) { report.flow(4, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '초안 사이트 생성 실패', file: 'portal/sites/api.mjs', route: 'POST /api/sites', expected: 'draft site', actual: e.message, repro: '30-sites.mjs', flows: [4] }); } } + if (!site) { for (const n of [5, 6, 7, 8, 9, 23]) report.flow(n, 'partial', { note: '사이트 없음', blockedBy: 4 }); return; } + // ---- 5 Webmaker 제작(SSO 진입 + 저장본) + { const s = steps(); try { + const sso = await s.run('제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)', async () => { const r = await alice.get(`/sso/webmaker?site=${site.id}`); if (r.status !== 302) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const loc = r.headers.location; const w = await fetch(loc, { redirect: 'manual' }); const again = await fetch(loc, { redirect: 'manual' }); if (w.status !== 302) throw new Error(`webmaker ${w.status}`); return { portalRedirect: loc.replace(/ticket=.*/, 'ticket=…'), webmaker: w.status, reuse: again.status }; }); + const ver = await s.run('Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과', async () => { await env.wm.addVersion(site.webmaker_project_id); const v = await alice.get(`/api/sites/${site.id}/versions`); const pc = await alice.get(`/api/sites/${site.id}/precheck`); if (!pc.json.precheck?.ok) throw new Error('precheck: ' + JSON.stringify(pc.json.precheck?.items?.filter((i) => i.state === 'block')).slice(0, 300)); return { versions: (v.json.versions || v.json.versionList || []).length, precheckOk: pc.json.precheck.ok }; }); + report.flow(5, 'verified_local', { steps: s.list, note: 'SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음)', evidence: { sso, ver } }); + } catch (e) { report.flow(5, 'partial', { steps: s.list, note: e.message }); } } + // ---- 6 관리형 배포 (실제 HAL → 실제 Agent) + let deployJob = null; { const s = steps(); try { + const d = await s.run('배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나', async () => { const a = await alice.post(`/api/sites/${site.id}/deploy`, { versionRef: 'current' }); if (a.status !== 200) throw new Error(a.text.slice(0, 300)); const b = await alice.post(`/api/sites/${site.id}/deploy`, { versionRef: 'current' }); const st = (await alice.get(`/api/sites/${site.id}`)).json.site; return { jobId: a.json.job.id, steps: a.json.job.steps.length, again: b.json.created, siteCount: st.site_count_state, server: st.server_id }; }); + deployJob = await s.run('HAL·Agent 를 거쳐 작업 완료 대기(최대 40초)', async () => { const j = await waitSiteJob(alice, site.id, d.jobId, 40000); const halAll = (await env.ops.get('/ops/api/jobs?kind=site.deploy&limit=20')).json?.jobs || []; const halJobs = { count: halAll.length, first: halAll[0] ? [halAll[0].status, (halAll[0].error || '').slice(0, 140)] : null }; const cur = (await alice.get(`/api/sites/${site.id}/jobs/${d.jobId}`)).json; const cj = cur.job || cur; if (!j || j.timedOut) throw new Error(`시간 초과 — 포털 작업 ${cj.status}(attempt ${cj.attempt}, ${cj.waitReason || cj.wait_reason || ''}) · HAL site.deploy 작업 ${halJobs.count}개(최근: ${JSON.stringify(halJobs.first)})`); if (j.status !== 'done') throw new Error(`${j.status}: ${j.error} (${j.errorCode}) 단계=${JSON.stringify((j.steps || []).map((x) => x.id + ':' + x.state))}`); return { status: j.status, verify: j.result?.verify, siteUrl: j.result?.siteUrl, steps: j.steps.map((x) => x.id + ':' + x.state) }; }); + const after = await s.run('배포 뒤 사이트 상태·버전·건강', async () => { const st = (await alice.get(`/api/sites/${site.id}`)).json.site; const ag = env.servers.web; return { lifecycle: st.lifecycle, version: st.current_version, verify: st.deploy_verify, health: st.health, agentHasSite: (await import('node:fs')).existsSync(`${ag.roots.sites}/${site.id}/current`) || (await import('node:fs')).existsSync(`${env.servers.web2.roots.sites}/${site.id}/current`) }; }); + report.flow(6, 'verified_local', { steps: s.list, note: '배포 요청→예약→HAL 작업→Agent(stub) 배포→도메인 설정 필요 상태(대표 도메인 없음)', evidence: { d, deployJob, after } }); + } catch (e) { + // 포털 경로가 막힌 경우: HAL·Agent 쪽 배포 경로(로컬 저장본) 만 따로 확인해 결함 위치를 좁힌다 + await s.run('[HAL 직접] 같은 Agent 에 로컬 저장본으로 site.deploy → 성공하면 결함은 포털→Agent 번들 전달 구간', async () => { const ag = env.servers.web; const { mkdirSync, writeFileSync, existsSync } = await import('node:fs'); const bdir = `${ag.dir}/bundle-direct`; mkdirSync(bdir, { recursive: true }); writeFileSync(`${bdir}/index.html`, '

direct marker-direct

'); const r = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'site.deploy', target: ag.serverId, payload: { siteId: 'direct_e2e', versionRef: 'v1', bundlePath: bdir, domains: [], expectMarker: 'marker-direct' }, idempotencyKey: 'e2e-direct-deploy' }); const j = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${r.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: j?.status, error: j?.error, layout: existsSync(`${ag.roots.sites}/direct_e2e/current`), externalCheck: j?.result?.externalCheck }; }, { soft: true }); + const halErr = (await env.ops.get('/ops/api/jobs?kind=site.deploy&limit=3')).json?.jobs?.map((x) => x.error).filter(Boolean)[0] || ''; + if (/Failed to parse URL|내려받기 실패/.test(halErr)) report.bug({ title: 'Agent 가 포털 저장본(bundleUrl, 절대 URL)을 내려받지 못함(HAL 주소를 앞에 덧붙임)', file: 'agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle)', route: 'HAL site.deploy → Agent fetch 단계', expected: 'bundleUrl 이 절대 URL 이면 그대로 내려받는다(서명은 HAL 전용이므로 포털 번들 URL 에는 붙이지 않음)', actual: halErr, repro: '포털 배포 → HAL site.deploy → Agent: "Failed to parse URL from http://http:///internal/bundles/…"', flows: [6, 8, 9, 13, 14, 15, 23] }); + const cj0 = (await alice.get(`/api/sites/${site.id}/jobs/${s.list[0]?.detail?.jobId || ''}`)).json; const pj = cj0 && (cj0.job || cj0); + if (pj && pj.status === 'queued' && pj.attempt > 2) report.bug({ title: '포털 배포 작업이 실패한 HAL 작업을 실패로 보지 않고 5초마다 새 HAL 작업을 무한 재제출(배포가 영원히 "진행 중")', file: 'portal/sites/service.mjs (halJobFor: prev HAL 작업이 failed/cancelled 면 fresh 가 아니어도 새 작업을 만든다 → awaitHal 이 실패를 보지 못함)', route: 'POST /api/sites/:id/deploy', expected: 'HAL 작업이 failed 면 포털 작업도 failed(이전 버전 유지·재시도 버튼), 새 HAL 작업은 사용자 재시도(fresh) 때만', actual: `portal job queued attempt ${pj.attempt} (${pj.waitReason || pj.wait_reason || ''}); HAL 에 같은 사이트의 site.deploy 실패 작업이 attempt 수만큼 쌓임`, repro: 'Agent 가 실패하는 배포를 요청하고 /api/sites/:id/jobs/:id 를 지켜보면 attempt 가 5초마다 증가', flows: [6, 23, 13, 20] }); + // 선행 결함으로 영원히 진행 중인 배포를 사용자 취소·정리해 뒤 흐름(백업·이전·소유권)이 "진행 중 작업" 에 막히지 않게 한다 + await s.run('사용자 취소(진행 중 작업) → 중간 결과 정리·예약 반환', async () => { const jid = s.list[0]?.detail?.jobId; const c = await alice.post(`/api/sites/${site.id}/jobs/${jid}/cancel`); const cl = await alice.post(`/api/sites/${site.id}/cleanup`); const st = (await alice.get(`/api/sites/${site.id}`)).json.site; return { cancel: c.status, cancelError: c.json?.error, cleanup: cl.status, cleanupError: cl.json?.error, siteCount: st.site_count_state, lifecycle: st.lifecycle }; }, { soft: true }); + report.flow(6, 'failed', { steps: s.list, note: e.message, evidence: { halLog: env.hal.proc.lines.filter((l) => /site.deploy/.test(l)).slice(-3) } }); + report.bug({ title: '관리형 배포 종단 흐름 실패(포털→HAL→Agent 저장본 전달)', file: 'agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle: client.download(p.bundleUrl))', route: 'site.deploy → Agent fetch 단계', expected: '절대 URL(bundleUrl=http:///internal/bundles/)은 HAL base 를 붙이지 않고 그대로 내려받아야 한다', actual: e.message, repro: '사이트 생성→저장본→POST /api/sites/:id/deploy→HAL site.deploy→Agent site.deploy fetch 단계에서 "HAL 내려받기 실패" 또는 잘못된 URL 오류', flows: [6, 8, 9, 13, 14, 15, 23] }); + } } + const deployed = deployJob && deployJob.status === 'done'; + // ---- 7 도메인 연결 + { const s = steps(); try { + const dm = await s.run('도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)', async () => { const r = await alice.post('/api/domains', { name: 'aw-e2e.localhost', purpose: 'web', siteId: site.id, makePrimary: true }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); const txt = r.json.records?.find((x) => x.type === 'TXT'); const j = await waitFor(async () => { const x = await alice.get(`/api/domains/${r.json.domain.id}`); const jj = x.json?.job; return jj && ['awaiting_external', 'failed', 'done', 'needs_user'].includes(jj.status) ? jj : null; }, { timeoutMs: 30000, intervalMs: 1000 }); return { domainId: r.json.domain.id, txtName: txt?.name, txtValueShape: txt?.value ? txt.value.replace(/=.*/, '=…') : null, jobStatus: j?.status, waitReason: j?.waitReason || j?.wait_reason, copyable: !!txt }; }); + await s.run('도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시', async () => { const l = await alice.get('/api/domains'); const d = l.json.domains.find((x) => x.id === dm.domainId); return { ownership: d?.ownership_state, dns: d?.dns_state, cert: d?.cert_state, primary: d?.is_primary }; }); + state.domainId = dm.domainId; + await s.run('(정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다', async () => { const x = await alice.get(`/api/domains/${dm.domainId}`); const jid = x.json?.job?.id; const r = jid ? await alice.post(`/api/domains/${dm.domainId}/jobs/${jid}/cancel`) : null; await sleep(1500); const q = (await env.ops.get('/ops/api/jobs?status=queued,running&kind=domain.probe&limit=50')).json?.jobs?.length; if (q > 3) report.bug({ title: '포털 도메인 연결 작업이 외부 대기(awaiting_external) 중에도 5초마다 HAL domain.probe 작업을 새로 만들어 HAL 큐를 채움', file: 'portal/domains/service.mjs (domain.connect: idempotencyKey 에 attempt·30초 버킷 포함, awaitHal 의 retryLater(5s) 마다 새 작업)', route: 'POST /api/domains → HAL /portal/v1/jobs(domain.probe)', expected: '한 번 확인 요청에 HAL 작업 하나; 재확인은 사용자의 "다시 확인" 또는 완만한 주기', actual: `취소 전 HAL 큐에 domain.probe ${q}개(5초 간격)`, repro: '도메인 추가 뒤 /ops/api/jobs?kind=domain.probe 를 보면 5초마다 새 queued 작업', flows: [7, 22, 30] }); return { cancelled: r?.status, queuedProbes: q }; }, { soft: true }); + report.needsInfra('도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인은 실제 DNS 와 공인 서버가 필요하다(흐름 7·8·11·14·15)'); + report.flow(7, 'partial', { steps: s.list, note: '도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가', evidence: { dm } }); + } catch (e) { report.flow(7, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '도메인 연결 흐름 실패', file: 'portal/domains/api.mjs', route: 'POST /api/domains', expected: 'TXT 안내 + 외부 대기', actual: e.message, repro: '30-sites.mjs 흐름 7', flows: [7] }); } } + report.flow(8, deployed ? 'partial' : 'not_verifiable_locally', { note: deployed ? '플랫폼 확인 주소(preview.localhost)로 HAL 외부 확인을 시도했으나 로컬에는 80 포트 웹서버가 없어 외부 확인 결과만 기록됨' : '외부 주소 확인은 공인 서버·DNS 필요', blockedBy: deployed ? null : 6, evidence: deployJob ? { externalCheck: deployJob.externalCheck } : {} }); + // ---- 9 사이트 정보·통계 + { const s = steps(); try { + const ov = await s.run('개요: 배포 전/후에 따라 준비 단계 또는 실제 통계', async () => { const r = await alice.get(`/api/sites/${site.id}/overview`); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const o = r.json.overview; return { kind: o.kind, lifecycle: o.lifecycle, hasStats: o.visits24h != null, readiness: o.readiness || o.stage || null, disk: o.disk ? { used: o.disk.used, limit: o.disk.limit } : null }; }); + const us = await s.run('사용량 탭(배정·실제 사용·마지막 계측)', async () => { const r = await alice.get(`/api/sites/${site.id}/usage`); return { allocations: (r.json.allocations || []).map((a) => a.resource) }; }); + report.flow(9, deployed && ov.hasStats ? 'verified_local' : 'partial', { steps: s.list, note: deployed ? '개요·사용량 조회(Agent stub 가 수집한 값)' : '미배포 사이트는 통계 대신 준비 단계 표시(§8.1) 확인. 실제 접속·네트워크 통계는 배포 뒤 수집', blockedBy: deployed ? null : 6, evidence: { ov, us } }); + } catch (e) { report.flow(9, 'partial', { steps: s.list, note: e.message }); } } + // ---- 23 실패 작업 재시도·되돌리기(배포 작업 기준) + { const s = steps(); try { + const dep = await s.run('배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호', async () => { const r = await alice.get(`/api/sites/${site.id}/deployments`); const d = r.json.deployments?.[0]; return { status: d?.status, failedStep: d?.result?.failedStep, canRetry: d?.job?.canRetry, jobId: d?.job?.id }; }); + const rt = await s.run('재시도 요청(POST /api/sites/:id/jobs/:jobId/retry)', async () => { if (!dep.jobId) throw new Error('작업 없음'); const r = await alice.post(`/api/sites/${site.id}/jobs/${dep.jobId}/retry`); if (r.status >= 500) report.bug({ title: '취소된 배포 작업의 재시도 요청이 500(서버 오류)으로 응답(작업 상태 오류는 4xx 여야 함)', file: 'portal/sites/api.mjs (jobs/:jobId/:action → lib/jobs JobError 를 HttpError 로 바꾸지 않음)', route: 'POST /api/sites/:id/jobs/:jobId/retry', expected: '409 job_not_retryable + 안내', actual: `${r.status} ${r.json?.error}`, repro: '취소된 작업에 retry', flows: [23] }); return { status: r.status, error: r.json?.error, jobStatus: r.json?.job?.status }; }, { soft: true }); + if (rt && rt.status === 200) await s.run('재시도 작업 결과 대기', async () => { const j = await waitSiteJob(alice, site.id, dep.jobId, 60000); return { status: j?.status, error: j?.error }; }, { soft: true }); + const rb = await s.run('되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부', async () => { const r = await alice.post(`/api/sites/${site.id}/rollback`); return { status: r.status, error: r.json?.error, message: r.json?.message }; }); + report.flow(23, deployed ? 'partial' : 'partial', { steps: s.list, note: '실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요', blockedBy: deployed ? null : 6, evidence: { dep, rt, rb } }); + } catch (e) { report.flow(23, 'partial', { steps: s.list, note: e.message }); } } + // ---- 27 마지막 사이트 수 동시 요청(밥의 개인 그룹: 베이직 1개) + { const s = steps(); try { + const bob = state.bob; const personal = (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal'); await bob.post('/api/groups/switch', { groupId: personal.id }); + await s.run('밥 개인 그룹: 베이직(사이트 1) 주문·승인', async () => orderAndApprove(env, bob, { productId: 'basic', key: 'e2e-bob-basic' }).then((r) => ({ granted: r.approve.granted }))); + const [a, b] = await s.run('사이트 2개 생성·저장본', async () => Promise.all([createSiteWithVersion(env, bob, '동시 A'), createSiteWithVersion(env, bob, '동시 B')])).then((x) => x); + const race = await s.run('두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족)', async () => { const [ra, rb] = await Promise.all([bob.post(`/api/sites/${a.id}/deploy`, {}), bob.post(`/api/sites/${b.id}/deploy`, {})]); const ok = [ra, rb].filter((r) => r.status === 200).length; const bad = [ra, rb].find((r) => r.status !== 200); const good = [ra, rb].find((r) => r.status === 200); if (ok !== 1 || !bad || bad.json?.error !== 'precheck_failed') throw new Error(`${ra.status}/${rb.status} ${bad?.json?.error}`); state.bobDeploy = good ? { siteId: good === ra ? a.id : b.id, jobId: good.json.job?.id } : null; return { ok, blocked: bad.json.error, shortage: bad.json.detail?.items?.find((i) => i.id === 'sites')?.state }; }); + await s.run('(정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환', async () => { if (!state.bobDeploy) return null; await sleep(3000); const c = await bob.post(`/api/sites/${state.bobDeploy.siteId}/jobs/${state.bobDeploy.jobId}/cancel`); const cl = await bob.post(`/api/sites/${state.bobDeploy.siteId}/cleanup`); return { cancel: c.status, cleanup: cl.status, unallocated: (await bob.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.unallocated }; }, { soft: true }); + state.bobSites = [a, b]; state.bobPersonal = personal.id; report.flow(27, 'verified_local', { steps: s.list, note: '마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3)', evidence: { race } }); + } catch (e) { report.flow(27, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '동시 예약 흐름 실패', file: 'portal/sites/service.mjs', route: 'POST /api/sites/:id/deploy ×2', expected: '하나만 성공', actual: e.message, repro: '30-sites.mjs 흐름 27', flows: [27] }); } } +} +function d_last(s) { return s.list.at(-1); } diff --git a/test/e2e/flows/40-mail.mjs b/test/e2e/flows/40-mail.mjs new file mode 100644 index 0000000..ba3e41a --- /dev/null +++ b/test/e2e/flows/40-mail.mjs @@ -0,0 +1,47 @@ +/** 흐름 10·11·12·17·33: 사이트 대표 메일·메일함(가짜 AriMail) → DNS 확인·활성화(외부 대기) → 왕복 확인(로컬 불가) → 독립 메일 → 사용량 경고/100% → 전환 점검표. */ +import { steps } from '../report.mjs'; +import { waitFor } from '../util.mjs'; +import { cancelDomainJob } from './30-sites.mjs'; +export const BUDGET_MS = 60000; +const alloc = { mailboxes: 6, storageBytes: 2 * 1024 ** 3, monthlyMessages: 500 }; +export async function run({ env, report, state }) { + const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [10, 11, 12, 17, 33]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; } + let svcId = null; + { const s = steps(); try { + const svc = await s.run('사이트 대표 메일 생성(사이트 연결 도메인·배정) → AriMail 테넌트 준비·도메인별 보장량·ariweb 표시', async () => { const r = await alice.post('/api/mail/services', { kind: 'site_bound', siteId: site.id, domain: 'aw-e2e.localhost', alloc }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const d = env.am.state.domains.get('aw-e2e.localhost'); const cfg = env.am.state.configs.get('aw-e2e.localhost') || {}; return { id: r.json.service.id, status: r.json.service.status, tenant: !!d, ariwebKind: d?.ariweb?.kind, limitAccounts: cfg.LIMIT_ACCOUNTS, siteCountAllocated: (await alice.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.allocated }; }); + svcId = svc.id; state.mailServiceId = svcId; + await s.run('같은 사이트에 두 번째 대표 메일 거부', async () => { const r = await alice.post('/api/mail/services', { kind: 'site_bound', siteId: site.id, domain: 'aw-e2e.localhost', alloc: { mailboxes: 1 } }); return r.json?.error; }); + const mb = await s.run('메일함 생성: 초기 비밀번호 1회 표시·접속 안내 → 재조회에는 비밀번호 없음', async () => { const r = await alice.post(`/api/mail/services/${svcId}/mailboxes`, { local: 'kim', displayName: '김', quotaBytes: 100 * 1024 ** 2 }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const again = await alice.get(`/api/mail/services/${svcId}/mailboxes/${r.json.mailbox.id}`); const leaked = JSON.stringify(again.json).includes(r.json.initialPassword); return { id: r.json.mailbox.id, address: r.json.mailbox.address, passwordShownOnce: !!r.json.initialPassword, mustChange: r.json.mailbox.mustChangePassword, imapHost: r.json.program?.imap?.host, leakedOnReload: leaked, inArimail: !!(env.am.state.users.get('aw-e2e.localhost') || []).find((u) => u.login === 'kim') }; }); + state.mailboxId = mb.id; + await s.run('초대 방식 메일함(링크 만료·재발송·사용 완료 상태)', async () => { const r = await alice.post(`/api/mail/services/${svcId}/mailboxes`, { local: 'lee', mode: 'invite', inviteEmail: 'lee@external.e2e.localhost' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const token = r.json.invite.url.split('/').pop(); const acc = await env.customer().post('/api/mail/invite/accept', { token, password: 'new-password-e2e-12' }); const again = await env.customer().post('/api/mail/invite/accept', { token, password: 'new-password-e2e-12' }); return { inviteState: r.json.mailbox.inviteState, accepted: acc.status, reuse: again.json?.error }; }); + const dns = await s.run('DNS 확인: 미반영/잘못된 값 구분 표시(로컬 DNS 에는 레코드 없음 → missing)', async () => { const r = await alice.post(`/api/mail/services/${svcId}/dns-check`); return { ok: r.json.result?.ok, missing: r.json.result?.summary?.missing, items: (r.json.result?.items || []).map((i) => i.kind + ':' + i.state) }; }); + const act = await s.run('활성화 작업 → DNS 외부 대기(awaiting_external) · 멱등', async () => { const a = await alice.post(`/api/mail/services/${svcId}/activate`); const b = await alice.post(`/api/mail/services/${svcId}/activate`); const j = await waitFor(async () => { const x = await alice.get(`/api/mail/services/${svcId}/job`); return x.json?.job && x.json.job.status !== 'queued' && x.json.job.status !== 'running' ? x.json.job : null; }, { timeoutMs: 20000, intervalMs: 500 }); return { status: j?.status, waitReason: j?.waitReason, sameJob: a.json?.job?.id === b.json?.job?.id, steps: (j?.steps || []).map((x) => x.id + ':' + x.state) }; }); + state.mailJobId = act && (await alice.get(`/api/mail/services/${svcId}/job`)).json?.job?.id; + await s.run('보안 보고서(SPF·DKIM·DMARC·MX 상태 + 복사 값)', async () => { const r = await alice.get(`/api/mail/services/${svcId}/security`); return { items: (r.json.report?.items || []).map((i) => i.id + ':' + i.state), records: (r.json.report?.records || []).length }; }); + report.needsInfra('메일 DNS(MX·SPF·DKIM·DMARC) 반영과 외부 송신·수신·회신 왕복은 실 도메인·실 AriMail(SMTP 587/수신함)·외부 검증 주소가 필요하다(흐름 10 활성화 완료·11·33 완료)'); + report.flow(10, 'partial', { steps: s.list, note: '대표 메일 생성(AriMail 테넌트·보장량·ariweb 표시)·메일함(비밀번호 1회·초대)·DNS 미반영 구분·활성화 작업 외부 대기까지 확인. DNS 반영 뒤 활성화 완료는 실 DNS 필요', evidence: { svc, mb, dns, act } }); + } catch (e) { report.flow(10, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '사이트 대표 메일·메일함 흐름 실패', file: 'portal/mail/api.mjs', route: 'POST /api/mail/services, /mailboxes', expected: '서비스·메일함 생성', actual: e.message, repro: '40-mail.mjs', flows: [10] }); } } + report.flow(11, 'not_verifiable_locally', { note: '외부 송신·수신·회신은 실제 SMTP 제출(587)·수신함·외부 자동 회신 주소가 필요하다. 포털은 검증 주소 미설정 시 needs_user, 왕복 실패 시 활성화하지 않는 규칙(단위 테스트 test/mail.test.mjs)으로만 확인됨' }); + // ---- 12 독립 메일 + { const s = steps(); try { + const d = await s.run('메일 전용 도메인 등록(solo-e2e.localhost, purpose mail)', async () => { const r = await alice.post('/api/domains', { name: 'solo-e2e.localhost', purpose: 'mail', startCheck: false }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const c = await cancelDomainJob(alice, r.json.domain.id); return { id: r.json.domain.id, ownership: r.json.domain.ownership_state, jobCancelled: c }; }); + const r = await s.run('독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비)', async () => { const x = await alice.post('/api/mail/services', { kind: 'standalone', domain: 'solo-e2e.localhost', alloc: { mailboxes: 2, storageBytes: 1024 ** 3, monthlyMessages: 100 } }); if (x.status !== 200) throw new Error(`${x.status} ${x.text.slice(0, 300)}`); const u = (await alice.get('/api/usage')).json.resources; return { id: x.json.service.id, kind: x.json.service.kind, mailServicesAllocated: u.find((y) => y.resource === 'mail_services')?.allocated, sitesAllocated: u.find((y) => y.resource === 'sites')?.allocated }; }); + state.standaloneMailId = r.id; const list = await alice.get('/api/mail/services'); + report.flow(12, 'verified_local', { steps: s.list, note: '독립 메일 서비스 생성: 독립 옵션 1 소비, 운영 사이트 수 미소비, 목록에 백업 미제공 문구', evidence: { r, listCount: list.json.services?.length, noBackupNote: list.json.notes?.noBackup } }); + } catch (e) { report.flow(12, 'partial', { steps: s.list, note: e.message }); } } + // ---- 17 사용량 경고·100%(메일 저장 공간·월간 통수) + { const s = steps(); try { + if (!svcId) throw new Error('메일 서비스 없음'); + const warn = await s.run('가짜 AriMail 사용량 95%/월간 100% → 수집(refresh) → 구간·제한 사유·다음 KST 초기화·마지막 계측 표시', async () => { env.am.state.usage.set('aw-e2e.localhost', { storageBytes: Math.round(0.96 * alloc.storageBytes), sent: 300, received: 250, monthReset: '2026-10-31T15:00:00.000Z' }); const kim = (env.am.state.users.get('aw-e2e.localhost') || []).find((u) => u.login === 'kim'); if (kim) kim.bytes = Math.round(0.97 * (kim.quotaBytes || 100 * 1024 ** 2)); const u = (await alice.get(`/api/mail/services/${svcId}/usage?refresh=1`)).json; const ev = await alice.post('/api/usage/evaluate', {}); u._evaluate = { status: ev.status, notified: ev.json?.notified?.length ?? ev.json?.result?.notified?.length, limited: ev.json?.limited?.length ?? ev.json?.result?.limited?.length }; return { storageLevel: u.figures?.storage?.level, monthlyLevel: u.figures?.monthly?.level, nextReset: u.nextReset, measuredAt: !!u.measuredAt, limit100: u.notes?.limit100?.monthly, countingRule: u.lastUsage?.countingRule, evaluate: u._evaluate }; }); + const notif = await s.run('알림 센터: 95%·100% 경고가 사건으로 묶여 표시(필수 알림)', async () => { const r = await alice.get('/api/notifications'); const items = r.json.notifications || r.json.items || []; const usage = items.filter((n) => n.category === 'usage' || /%/.test(n.title)); return { count: usage.length, titles: usage.slice(0, 4).map((n) => n.title), mandatory: usage.some((n) => n.mandatory) }; }); + const lim = await s.run('사용량 화면 제한 플래그(/api/usage/limits)·그룹 요약', async () => { const r = await alice.get('/api/usage/limits'); const u = await alice.get('/api/usage'); return { limits: (r.json.limits || []).map((l) => l.resource + ':' + l.level), warnLevels: u.json.product?.warnLevels }; }); + if (!notif.count) report.bug({ title: '메일 사용량 수집 뒤 서비스 단위 70/80/90/95/100 경고 알림이 생성되지 않음(수집과 경고 엔진 연결 누락 가능)', file: 'portal/mail/service.mjs (collectMailUsage) ↔ portal/usage/warnings.mjs (evaluate)', route: 'GET /api/mail/services/:id/usage?refresh=1 → POST /api/usage/evaluate', expected: '저장 공간 95%·월간 100% 수집 뒤 알림 센터에 경고/제한 알림(필수)', actual: { notifications: notif, limits: lim.limits, evaluate: warn.evaluate }, repro: '가짜 AriMail 사용량을 95%/100% 로 두고 refresh=1 수집 → /api/notifications 에 usage 알림 없음', flows: [17] }); + report.flow(17, notif.count ? 'verified_local' : 'partial', { steps: s.list, note: '메일 저장 공간 95%·월간 통수 100% 를 수집해 구간·제한 사유·KST 초기화·알림을 확인(웹 디스크/아웃바운드는 배포가 있어야 수집)', evidence: { warn, notif, lim } }); + } catch (e) { report.flow(17, 'partial', { steps: s.list, note: e.message }); } } + // ---- 33 전환 점검표 + { const s = steps(); try { + const ck = await s.run('사이트 대표→독립 전환 점검표(주소·메일함·별칭·전달·비밀번호·DNS·옵션·작업)', async () => { const r = await alice.get(`/api/mail/services/${svcId}/convert`); return { canConvert: r.json.checklist?.canConvert, items: (r.json.checklist?.items || []).map((i) => i.id + ':' + (i.ok ? 'ok' : 'x')) }; }); + const tr = await s.run('DNS 미확인 상태에서 전환 요청은 차단', async () => { const r = await alice.post(`/api/mail/services/${svcId}/convert`, { confirm: 'aw-e2e.localhost' }); return { status: r.status, error: r.json?.error }; }); + report.flow(33, 'partial', { steps: s.list, note: '전환 점검표 8항목과 미충족 시 차단 확인. 전후 실제 송수신 확인·DNS 유지 검증은 실 DNS/AriMail 필요(단위 테스트 test/mail.test.mjs 에서 가짜로 전환 완료·되돌리기 확인)', evidence: { ck, tr } }); + } catch (e) { report.flow(33, 'partial', { steps: s.list, note: e.message }); } } +} diff --git a/test/e2e/flows/50-backup.mjs b/test/e2e/flows/50-backup.mjs new file mode 100644 index 0000000..5308f6e --- /dev/null +++ b/test/e2e/flows/50-backup.mjs @@ -0,0 +1,35 @@ +/** 흐름 13·14·15: 백업 옵션 구매(사이트별)·첫 백업(실제 HAL→Agent 저장소 분리), 복원(승인·보호 시점·사용자 확인), 서버 이전 경계값·요청. */ +import { steps } from '../report.mjs'; +import { waitFor } from '../util.mjs'; +import { orderAndApprove } from './20-commerce.mjs'; +export const BUDGET_MS = 90000; +async function waitJob(client, path, ms = 60000) { return waitFor(async () => { const r = await client.get(path); const j = r.json?.job || r.json?.restore?.job || null; return j && !['queued', 'running'].includes(j.status) ? j : null; }, { timeoutMs: ms, intervalMs: 1000 }); } +export async function run({ env, report, state }) { + const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [13, 14, 15]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; } + const cur = (await alice.get(`/api/sites/${site.id}`)).json.site; const deployed = !!cur.current_version; + { const s = steps(); try { + await s.run('백업 옵션 주문(대상 사이트)·입금 확인·승인 → backup 권리', async () => orderAndApprove(env, alice, { productId: 'team', items: [{ optionId: 'backup', qty: 1, targetId: site.id }], key: 'e2e-backup-opt' }).then((r) => ({ granted: r.approve.granted, orderStatus: r.approve.order?.status }))); + const el = await s.run('백업 화면: 구매 가능 여부·저장 한도·일정·보존·첫 백업 기한·검증 방식(상품정책에서)', async () => { const r = await alice.get(`/api/backup/sites/${site.id}`); return { eligible: r.json.purchase?.eligible, reasons: r.json.eligibility?.reasons, deferred: r.json.purchase?.deferred, policy: r.json.purchase?.policy ? Object.keys(r.json.purchase.policy) : null, status: r.status }; }); + const opt = await s.run('옵션 활성화(확인 필수) → 첫 백업 예정 시각', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/option`, { confirm: true }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); return { status: r.json.option.status, firstDue: r.json.option.first_backup_due_at, nextRun: r.json.option.next_run_at }; }); + const run1 = await s.run('지금 백업 실행 → HAL backup.create(저장 서버 ≠ 운영 서버) → Agent → 검증', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/run`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const j = await waitFor(async () => { const x = await alice.get(`/api/backup/sites/${site.id}`); const b = (x.json.backups || [])[0]; const jj = x.json.option?.lastJob || x.json.lastJob || null; return (b && b.status !== 'running') || (jj && !['queued', 'running'].includes(jj.status)) ? { backup: b, job: jj, option: x.json.option } : null; }, { timeoutMs: 25000, intervalMs: 1500 }); const bj = (await alice.get(`/api/backup/sites/${site.id}`)).json; return { timedOut: !!j?.timedOut, optionStatus: bj.option?.status, lastJob: bj.option?.lastJob || bj.lastJob || null, siteServer: (await alice.get(`/api/sites/${site.id}`)).json.site?.server_id || null, backup: j?.backup ? { status: j.backup.status, verify: j.backup.verify_state, tier: j.backup.tier, location: j.backup.location ? j.backup.location.split(':')[0] : null } : null, job: j?.job ? { status: j.job.status, error: j.job.error } : null, optionFailures: j?.option?.consecutive_failures }; }); + state.backupOk = run1.backup && run1.backup.status === 'done'; + report.flow(13, state.backupOk ? 'verified_local' : 'partial', { steps: s.list, note: state.backupOk ? '옵션 구매·활성화·첫 백업(HAL→운영 Agent 생성→저장 Agent 보관→검증)' : (deployed ? '옵션 구매·활성화까지 확인, 백업 실행 결과는 증거 참조' : '옵션 구매·활성화까지 확인. 실제 백업 생성은 배포된 사이트 파일이 있어야 함(흐름 6 결함에 막힘)'), blockedBy: state.backupOk || deployed ? null : 6, evidence: { el, opt, run1 } }); + } catch (e) { report.flow(13, 'partial', { steps: s.list, note: e.message, blockedBy: deployed ? null : 6 }); } } + { const s = steps(); try { + const b = (await alice.get(`/api/backup/sites/${site.id}`)).json.backups?.find((x) => x.status === 'done'); + if (!b) throw new Error('완료된 백업이 없어 복원을 요청할 수 없음'); + const info = await s.run('복원 안내(포함 데이터·영향·보호 시점 유지·되돌릴 수 있는 마지막 시각·완료 방식)', async () => (await alice.get(`/api/backup/backups/${b.id}`)).json.restoreInfo); + const rq = await s.run('복원 요청 → 운영자 승인 대기 · 중복 요청 없음', async () => { const r = await alice.post(`/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const r2 = await alice.post(`/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); return { id: r.json.restore?.id, status: r.json.restore?.status, dup: r2.json?.created, finalConfirmation: r.json.finalConfirmation }; }); + const ap = await s.run('운영자 최종 확인(그룹·사이트·백업 시각) 불일치 거부 → 일치 승인(멱등)', async () => { const bad = await env.relay('POST', `/backup/restores/${rq.id}/approve`, { operator: 'e2e', confirm: { groupId: 'wrong', siteId: site.id, backupAt: b.created_at } }); const ok = await env.relay('POST', `/backup/restores/${rq.id}/approve`, { operator: 'e2e', confirm: { groupId: site.group_id, siteId: site.id, backupAt: b.created_at } }); return { bad: bad.json?.error, ok: ok.status, created: ok.json?.created }; }); + const j = await s.run('9단계 작업 진행 → 사용자 확인 필요(보호 시점·되돌리기 가능)', async () => { const jj = await waitJob(alice, `/api/backup/restores/${rq.id}`, 60000); return { status: jj?.status, steps: (jj?.steps || []).map((x) => x.id + ':' + x.state), actions: jj?.actions?.map((a) => a.id), error: jj?.error }; }); + if (j.status === 'needs_user') await s.run('사용자 확인 → 완료', async () => { const r = await alice.post(`/api/backup/restores/${rq.id}/decision`, { action: 'confirm' }); const jj = await waitJob(alice, `/api/backup/restores/${rq.id}`, 30000); return { status: jj?.status }; }); + report.flow(14, j.status === 'done' || j.status === 'needs_user' ? 'partial' : 'partial', { steps: s.list, note: '복원 요청·운영자 최종 확인(불일치 거부·승인 멱등)·단계 진행까지. 외부 사이트 확인은 공인 서버 필요', evidence: { info, rq, ap, j } }); + } catch (e) { report.flow(14, 'partial', { steps: s.list, note: e.message, blockedBy: state.backupOk ? null : 13 }); } } + { const s = steps(); try { + const b = await s.run('이전 화면 경계값: 취소 가능 마지막 단계·쓰기 제한·목표 중단 시간·되돌림 기간·이전 위치 보존·비용(플랫폼 사유 0원)', async () => { const r = await alice.get(`/api/relocation/sites/${site.id}`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); return { auto: r.json.auto, boundaries: r.json.boundaries ? { lastCancel: r.json.boundaries.customer?.lastCancelLabel, revertHours: r.json.boundaries.customer?.revertHours, cost: r.json.boundaries.customer?.cost, platformCost: r.json.boundaries.platform?.cost } : null, servers: r.json.servers?.length }; }); + const req = await s.run('고객 요청(유료·승인 대기) 또는 조건 미충족 사유', async () => { const r = await alice.post(`/api/relocation/sites/${site.id}`, { confirm: true, reason: 'e2e' }); return { status: r.status, error: r.json?.error, relocation: r.json?.relocation ? { status: r.json.relocation.status, kind: r.json.relocation.kind, cost: r.json.relocation.cost } : null, message: r.json?.message }; }); + const cur2 = req.relocation ? (await alice.get(`/api/relocation/sites/${site.id}`)).json : null; const relId = cur2?.current?.id || cur2?.relocation?.id || cur2?.relocations?.[0]?.id || null; state.relocationId = relId; + if (relId) await s.run('전환 전 고객 취소 가능(요청 취소 → 소유권 이전 등 다른 작업 차단 해제)', async () => { const r = await alice.post(`/api/relocation/${relId}/cancel`); return { status: r.status, rstatus: r.json?.relocation?.status, error: r.json?.error }; }, { soft: true }); + report.flow(15, 'partial', { steps: s.list, note: '이전 경계값·요청 규칙 확인. 실제 두 Agent 간 사전 복사·전환·외부 확인은 배포된 사이트(흐름 6)와 공인 확인 주소가 필요', blockedBy: deployed ? null : 6, evidence: { b, req } }); + } catch (e) { report.flow(15, 'partial', { steps: s.list, note: e.message }); } } +} diff --git a/test/e2e/flows/60-external.mjs b/test/e2e/flows/60-external.mjs new file mode 100644 index 0000000..35b475e --- /dev/null +++ b/test/e2e/flows/60-external.mjs @@ -0,0 +1,47 @@ +/** 흐름 16·28: 외부 배포 사이트 — 동의(버전 기록) → Agent 토큰(1회) → 실제 외부 Agent 프로세스 등록·연결 → 배포 → 재배포 → 제거 → 실패 정리. */ +import { steps } from '../report.mjs'; +import { waitFor, sleep } from '../util.mjs'; +import { startAgent } from '../boot.mjs'; +import { createSiteWithVersion, waitSiteJob } from './30-sites.mjs'; +export const BUDGET_MS = 120000; +export async function run({ env, report, state }) { + const alice = state.alice; if (!alice) { report.flow(16, 'partial', { note: '선행 흐름 없음', blockedBy: 1 }); report.flow(28, 'partial', { note: '선행 흐름 없음', blockedBy: 1 }); return; } + const s = steps(); let site = null; let tok = null; let agent = null; + try { + site = await s.run('외부 배포 사이트 생성(초안) + 저장본', async () => createSiteWithVersion(env, alice, 'E2E 외부 사이트', { deployMode: 'external' })); + const mode = await s.run('배포 방식 화면: 외부 대상·고객 책임·미제공 기능(메일 미지원)·건별 비용·동의 버전', async () => { const r = await alice.get(`/api/sites/${site.id}/mode`); return { notProvided: r.json.external?.notProvided, consentVersion: r.json.consent?.version, agreed: r.json.consent?.agreed, cost: r.json.external?.cost }; }); + await s.run('동의 전 Agent 발급 거부(consent_required) → 동의 기록(버전)', async () => { const a = await alice.post(`/api/sites/${site.id}/external`, {}); const c = await alice.post(`/api/sites/${site.id}/consent`, { version: mode.consentVersion, agree: true }); return { before: a.json?.error, consent: c.status }; }); + tok = await s.run('외부 Agent 일회용 토큰·설치 명령(토큰은 저장하지 않음)', async () => { const r = await alice.post(`/api/sites/${site.id}/external`, { label: '고객 서버' }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)} | portal: ${env.portal.proc.lines.filter((l) => /external 500/.test(l)).slice(-1).join('').slice(0, 300)}`); return { token: r.json.token, install: r.json.installCommand?.replace(/--token \S+/, '--token …'), expiresAt: r.json.expiresAt, status: r.json.agent?.status }; }, { soft: true }); + if (!tok) { + report.bug({ title: '외부 Agent 토큰 발급이 DB 제약(external_agents.hal_agent_id NOT NULL)으로 500', file: 'portal/sites/service.mjs (createExternalAgent) / portal/sites/model.mjs (external_agents.hal_agent_id)', route: 'POST /api/sites/:id/external', expected: 'HAL 토큰 발급 응답의 agentId 가 null(등록 전)이어도 토큰·설치 명령을 돌려주고, Agent 등록 뒤 by-site 조회로 hal_agent_id 를 채운다', actual: env.portal.proc.lines.filter((l) => /external 500/.test(l)).slice(-1).join('').slice(0, 300) || 'ERR_SQLITE_ERROR', repro: '외부 배포 사이트 → 동의 → POST /api/sites/:id/external → 500 "NOT NULL constraint failed: external_agents.hal_agent_id"', flows: [16, 28] }); + // 포털 결함을 우회하지 않고, HAL·Agent 쪽 외부 Agent 경로만 별도로 확인한다(흐름 28 의 Agent 연결·배포·제거·실패 정리) + const halTok = await s.run('[HAL 직접] 외부 Agent 토큰 발급(포털 서비스 키)', async () => { const r = await env.halSigned('POST', '/portal/v1/external-agents/token', { groupId: site.group_id, siteId: site.id, label: 'e2e' }); if (r.status !== 200) throw new Error(r.text?.slice(0, 200)); return { token: r.json.token, expiresAt: r.json.expiresAt }; }, { soft: true }); + if (halTok) { + agent = await s.run('[HAL 직접] 외부 Agent 등록·실행(사이트 귀속) → by-site 상태 connected', async () => { const a = await startAgent(env, { name: 'ext-1', role: 'web', token: halTok.token, external: true, siteId: site.id }); const st = await waitFor(async () => { const r = await env.halSigned('GET', `/portal/v1/external-agents/by-site/${site.id}`); return r.json?.agent?.status === 'connected' ? r.json.agent : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { agentId: a.agentId, status: st?.status, siteId: st?.siteId }; }, { soft: true }) && env.agents.at(-1); + const run = async (kind, payload) => { const r = await env.halSigned('POST', '/portal/v1/jobs', { kind, target: agent.agentId, payload, idempotencyKey: `e2e:${kind}:${Date.now()}` }); const j = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${r.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: j?.status, error: j?.error, errorCode: j?.errorCode, result: j?.result && { version: j.result.version, leftovers: j.result.leftovers, removed: j.result.removed } }; }; + if (agent) { + const { mkdirSync, writeFileSync, existsSync } = await import('node:fs'); const bdir = `${agent.dir}/bundle-v1`; mkdirSync(bdir, { recursive: true }); writeFileSync(`${bdir}/index.html`, '

ext marker-ext-1

'); + const d1 = await s.run('[HAL 직접] 외부 Agent 배포(site.deploy, 로컬 저장본) → releases/current 생성', async () => ({ ...(await run('site.deploy', { siteId: site.id, versionRef: 'v1', bundlePath: bdir, domains: [], expectMarker: 'marker-ext-1' })), layout: existsSync(`${agent.roots.sites}/${site.id}/current`) }), { soft: true }); + const d2 = await s.run('[HAL 직접] 재배포(v2) → 이전 버전 기록', async () => run('site.deploy', { siteId: site.id, versionRef: 'v2', bundlePath: bdir, domains: [], expectMarker: 'marker-ext-1' }), { soft: true }); + const d3 = await s.run('[HAL 직접] 실패 배포(표식 불일치) → 이전 버전 유지·남은 파일(leftovers) 보고', async () => run('site.deploy', { siteId: site.id, versionRef: 'v3', bundlePath: bdir, domains: [], expectMarker: 'no-such-marker' }), { soft: true }); + const d4 = await s.run('[HAL 직접] 다른 사이트 작업 거부(site_mismatch) · 허용되지 않는 종류 거부', async () => { const a = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'site.deploy', target: agent.agentId, payload: { siteId: 'other', bundlePath: bdir }, idempotencyKey: 'e2e-mismatch' }); const ja = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${a.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 10000, intervalMs: 1000 }); const b = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'backup.create', target: agent.agentId, payload: { siteId: site.id }, idempotencyKey: 'e2e-kind' }); const jb = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${b.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 10000, intervalMs: 1000 }); return { mismatch: ja?.errorCode, kind: jb?.errorCode }; }, { soft: true }); + const d5 = await s.run('[HAL 직접] 배포 제거(site.remove) → 파일·vhost 제거', async () => ({ ...(await run('site.remove', { siteId: site.id, keepData: false })), layoutAfter: existsSync(`${agent.roots.sites}/${site.id}/current`) }), { soft: true }); + const okAll = d1?.status === 'done' && d2?.status === 'done' && d3?.status === 'failed' && d5?.status === 'done'; + report.flow(28, okAll ? 'partial' : 'partial', { steps: s.list, note: 'HAL·외부 Agent 경로(등록·연결·배포·재배포·실패 시 이전 버전 유지와 남은 파일·다른 사이트/허용되지 않는 종류 거부·제거)는 확인. 포털 화면 경로는 토큰 발급 결함으로 막힘', evidence: { halTok: { expiresAt: halTok.expiresAt }, d1, d2, d3, d4, d5 } }); + } + } + report.flow(16, 'failed', { steps: s.list, note: '동의(버전)·토큰 발급 거부/허용 규칙까지 확인했으나 토큰 발급 API 가 DB 제약으로 500 — 결함 항목 참조', evidence: { mode } }); return; + } + agent = await s.run('고객 서버 역할의 외부 Agent 프로세스 등록·실행(HAL 등록, 사이트 귀속)', async () => { const a = await startAgent(env, { name: 'ext-1', role: 'web', token: tok.token, external: true, siteId: site.id }); return { agentId: a.agentId }; }) && env.agents.at(-1); + const conn = await s.run('포털 연결 확인(외부 Agent 상태 connected)', async () => { const r = await waitFor(async () => { const x = await alice.get(`/api/sites/${site.id}/external`); return x.json?.agent?.status === 'connected' ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: r?.agent?.status, stale: r?.agent?.stale, staleReason: r?.agent?.staleReason, halAgentId: r?.agent?.hal_agent_id }; }); + if (conn.status !== 'connected') { report.bug({ title: '외부 Agent 가 HAL 에 등록되어도 포털 화면은 연결 상태를 알지 못함', file: 'portal/sites/service.mjs (createExternalAgent/refreshExternalAgent)', route: 'GET /api/sites/:id/external', expected: 'Agent 등록 뒤 connected', actual: conn, repro: '외부 토큰 발급 → agent register --external --site → GET /api/sites/:id/external. HAL 토큰 발급 응답의 agentId 가 null 이라 포털 external_agents.hal_agent_id 가 비어 getExternalAgent(null) 로 조회됨 — by-site 조회(getExternalAgentBySite)로 보완 필요', flows: [16, 28] }); } + const pc = await s.run('배포 전 확인(외부: 관리형 용량 확인 없음)', async () => { const r = await alice.get(`/api/sites/${site.id}/precheck`); return { ok: r.json.precheck?.ok, blocks: (r.json.precheck?.items || []).filter((i) => i.state === 'block').map((i) => i.id) }; }); + const dep = await s.run('외부 배포 → 외부 Agent 가 site.deploy 수행', async () => { const r = await alice.post(`/api/sites/${site.id}/deploy`, {}); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const j = await waitSiteJob(alice, site.id, r.json.job.id, 60000); return { status: j?.status, error: j?.error, errorCode: j?.errorCode, steps: (j?.steps || []).map((x) => x.id + ':' + x.state) }; }, { soft: true }); + const ov = await s.run('외부 사이트 개요: 관리형 지표 없음·제공되지 않음 표기·Agent 상태·마지막 배포 결과', async () => { const r = await alice.get(`/api/sites/${site.id}/overview`); const o = r.json.overview; return { kind: o.kind, disk: o.disk, health: o.health, notProvided: o.notProvided, agent: o.agent?.status, lastDeployOk: o.lastDeployResult?.ok }; }); + const rm = await s.run('배포 제거(외부 서버 파일·실행 상태 제거, 실패 잔여 정리)', async () => { const r = await alice.post(`/api/sites/${site.id}/external/remove`); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const j = await waitSiteJob(alice, site.id, r.json.job?.id || r.json.id, 40000); return { status: j?.status, error: j?.error }; }, { soft: true }); + const rv = await s.run('Agent 연결 해제(revoke) → 상태 없음', async () => { const r = await alice.del(`/api/sites/${site.id}/external`); const x = await alice.get(`/api/sites/${site.id}/external`); return { status: r.status, agent: x.json?.agent?.status || null }; }, { soft: true }); + const deployOk = dep && dep.status === 'done'; + report.flow(16, deployOk ? 'verified_local' : 'partial', { steps: s.list, note: deployOk ? '동의→토큰→실제 외부 Agent 등록·연결→배포→개요(제공되지 않음)→제거' : '동의(버전)·토큰 1회·실제 외부 Agent 등록·개요의 제공되지 않음 표기까지 확인. 배포 결과는 증거 참조', evidence: { mode, tok: { install: tok?.install, expiresAt: tok?.expiresAt }, conn, pc, dep, ov } }); + report.flow(28, deployOk && rm && rm.status === 'done' ? 'verified_local' : 'partial', { steps: s.list, note: '외부 Agent 연결·제거·해제 API 확인. 재배포·실패 정리는 배포 성공이 필요(증거 참조)', evidence: { conn, dep, rm, rv } }); + } catch (e) { report.flow(16, 'failed', { steps: s.list, note: e.message }); report.flow(28, 'partial', { steps: s.list, note: e.message }); report.bug({ title: '외부 배포 흐름 실패', file: 'portal/sites/api.mjs', route: '/api/sites/:id/external', expected: '동의→토큰→연결→배포', actual: e.message, repro: '60-external.mjs', flows: [16, 28] }); } +} diff --git a/test/e2e/flows/65-usage.mjs b/test/e2e/flows/65-usage.mjs new file mode 100644 index 0000000..8f116b7 --- /dev/null +++ b/test/e2e/flows/65-usage.mjs @@ -0,0 +1,24 @@ +/** 흐름 18(+13.2/13.3 보조): 상품 축소 미리보기 → 보류(정리 목록) → 배정 축소 정리 → 재검증 → 축소 주문·승인 → 권리 교체. 배정 변경 동시성도 확인. */ +import { steps } from '../report.mjs'; +import { signupVerified } from './10-identity.mjs'; +import { orderAndApprove } from './20-commerce.mjs'; +import { cancelDomainJob } from './30-sites.mjs'; +export const BUDGET_MS = 40000; +export async function run({ env, report, state }) { + const alice = state.alice; if (!alice || !state.mailServiceId) { report.flow(18, 'partial', { note: '선행 흐름 없음', blockedBy: 10 }); return; } + const s = steps(); + try { + const basic = (await alice.get('/api/commerce/products')).json.products.find((p) => p.productId === 'basic'); + const alloc = await s.run('배정 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대) + 마지막 단위 동시 요청', async () => { const sub = `mail_service:${state.mailServiceId}`; const r = await alice.get(`/api/usage/allocate/${sub}`); const mb = r.json.resources?.find((x) => x.resource === 'mailboxes'); const [a, b] = await Promise.all([alice.post(`/api/usage/allocate/${sub}`, { resource: 'mailboxes', amount: mb.purchased }), alice.post(`/api/usage/allocate/${sub}`, { resource: 'mailboxes', amount: mb.purchased })]); return { purchased: mb?.purchased, mine: mb?.mine, others: mb?.others, unallocated: mb?.unallocated, race: [a.status, b.status].sort(), mineAfter: (await alice.get(`/api/usage/allocate/${sub}`)).json.resources?.find((x) => x.resource === 'mailboxes')?.mine }; }); + // 축소는 별도 고객(캐럴: 팀 상품 + 대표 메일 6개 배정)으로 — 앨리스 그룹의 백업·독립 메일 보존 상태와 섞이지 않게 + const carol = await signupVerified(env, 'carol@e2e.localhost', '캐럴'); await orderAndApprove(env, carol, { productId: 'team', key: 'e2e-carol-team' }); + const csite = (await carol.post('/api/sites', { name: '캐럴 사이트', projectMode: 'none' })).json.site; const cd = await carol.post('/api/domains', { name: 'carol-e2e.localhost', purpose: 'web', siteId: csite.id, startCheck: false }); if (cd.json?.domain?.id) await cancelDomainJob(carol, cd.json.domain.id); + const cm = await carol.post('/api/mail/services', { kind: 'site_bound', siteId: csite.id, domain: 'carol-e2e.localhost', alloc: { mailboxes: 6, storageBytes: 1024 ** 3, monthlyMessages: 100 } }); if (cm.status !== 200) throw new Error('캐럴 메일 생성 실패 ' + cm.text.slice(0, 200)); + const pv = await s.run('축소 미리보기(팀→베이직): 줄여야 할 항목(메일함 6→5) 구체 표시', async () => { const r = await carol.post('/api/commerce/downsize/preview', { productVersionId: basic.id }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); return { fits: r.json.fits, shortages: (r.json.shortages || []).map((x) => x.resource + ':' + x.reduceBy) }; }); + const hold = await s.run('축소 요청 → 보류(사용 중 권리 즉시 회수 없음)', async () => { const r = await carol.post('/api/commerce/downsize/request', { productVersionId: basic.id, acceptTerms: true }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); const ent = (await carol.get('/api/commerce/billing')).json.entitlements?.find((e) => e.resource === 'sites'); return { fits: r.json.fits, hold: r.json.hold?.status, sitesStillPurchased: ent?.purchased }; }); + const clean = await s.run('정리: 메일함 배정 6→5 축소 → 재검증 적합 → 축소 주문 생성', async () => { const a = await carol.post(`/api/usage/allocate/mail_service:${cm.json.service.id}`, { resource: 'mailboxes', amount: 5 }); const r = await carol.post('/api/commerce/downsize/revalidate'); return { allocate: a.status, allocateError: a.json?.error, fits: r.json.fits, shortages: (r.json.shortages || []).map((x) => x.resource + ':' + x.reduceBy), order: r.json.order?.status }; }); + let applied = null; + if (clean.fits && clean.order) { applied = await s.run('축소 주문 입금 확인·승인 → 권리 교체(베이직)', async () => { const o = (await carol.get('/api/commerce/orders')).json.orders.find((x) => x.kind === 'downsize'); await env.relay('POST', `/commerce/orders/${o.id}/paid`, { ref: 'e2e' }); const ap = await env.relay('POST', `/commerce/orders/${o.id}/approve`, {}); const b = await carol.get('/api/commerce/billing'); return { approve: ap.status, sites: b.json.entitlements?.find((e) => e.resource === 'sites')?.purchased, mailboxes: b.json.entitlements?.find((e) => e.resource === 'mailboxes')?.purchased, product: b.json.subscriptions?.[0]?.product_name || b.json.subscriptions?.[0]?.product_version_id }; }); state.downsized = true; } + report.flow(18, applied ? 'verified_local' : 'partial', { steps: s.list, note: applied ? '축소 미리보기→보류→정리(배정 축소·독립 메일 해지)→재검증 적합→축소 주문·승인→권리 교체' : '축소 보류와 정리 목록까지 확인(재검증 결과는 증거 참조)', evidence: { alloc, pv, hold, clean, applied } }); + } catch (e) { report.flow(18, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '상품 축소 보류·재검증 흐름 실패', file: 'portal/commerce/api.mjs', route: '/api/commerce/downsize/*', expected: '보류→정리→적용', actual: e.message, repro: '65-usage.mjs', flows: [18] }); } +} diff --git a/test/e2e/flows/70-lifecycle.mjs b/test/e2e/flows/70-lifecycle.mjs new file mode 100644 index 0000000..af6079c --- /dev/null +++ b/test/e2e/flows/70-lifecycle.mjs @@ -0,0 +1,46 @@ +/** 흐름 19·20·21·32·34: 사이트 수 반환 점검표·대표 메일 전환 조건, 소유권 이전(요청→수락→보류→재확인→심사·승인→원자적 완료), 중지·해지·보존 표시, Source 옵션 만료 읽기 전용. */ +import { steps } from '../report.mjs'; +import { orderAndApprove } from './20-commerce.mjs'; +export const BUDGET_MS = 60000; +export async function run({ env, report, state }) { + const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [19, 20, 21, 32, 34]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; } + const cur = (await alice.get(`/api/sites/${site.id}`)).json.site; const live = ['live', 'needs_domain'].includes(cur.lifecycle); + // ---- 32 Source 옵션 + { const s = steps(); try { + await s.run('Source 옵션 주문(대상 사이트)·승인 → source 권리', async () => orderAndApprove(env, alice, { productId: 'team', items: [{ optionId: 'source', qty: 1, targetId: site.id }], key: 'e2e-source-opt' }).then((r) => ({ granted: r.approve.granted })), { soft: true }); + const act = await s.run('Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보', async () => { const a = await alice.post(`/api/source/${site.id}/activate`); const r = await alice.get(`/api/source/${site.id}`); return { activate: a.status, error: a.json?.error, status: r.json.option?.status, provider: r.json.source?.provider, changes: r.json.source?.changes?.length, clone: !!r.json.cloneInfo }; }); + const ex = await s.run('export(저장본 내려받기)', async () => { const r = await alice.get(`/api/source/${site.id}/export`); return { status: r.status, type: r.headers['content-type'] }; }); + const un = await s.run('옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단', async () => { const r = await alice.post(`/api/source/${site.id}/unlink`); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); return { status: r.json.option?.status, deleteAfter: r.json.option?.delete_after, readonlyUntil: r.json.option?.readonly_until }; }); + const sweep = await s.run('삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음', async () => { const r = await env.relay('POST', '/source/sweep', {}); const x = await alice.get(`/api/source/${site.id}`); return { sweep: r.status, statusAfter: x.json.option?.status }; }); + report.flow(32, 'partial', { steps: s.list, note: '활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인)', evidence: { act, ex, un, sweep } }); + } catch (e) { report.flow(32, 'partial', { steps: s.list, note: e.message }); } } + // ---- 21·34 생명주기 표시 + { const s = steps(); try { + const v = await s.run('§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림', async () => { const r = await alice.get(`/api/lifecycle/sites/${site.id}`); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const view = r.json.view || {}; return { state: view.site?.label, siteCount: view.siteCountConsumed, publicAccess: view.publicAccess, retention: view.retentionPolicy, pauseImpactKeys: Object.keys(r.json.pauseImpact || {}), cancelImpactKeys: Object.keys(r.json.cancelImpact || {}), steps14: r.json.siteCount?.steps?.length }; }); + const pause = await s.run('일시 중지(확인 필수) 또는 현재 상태에서 거부 사유', async () => { const r0 = await alice.post(`/api/lifecycle/sites/${site.id}/pause`, {}); const r = await alice.post(`/api/lifecycle/sites/${site.id}/pause`, { confirm: true }); return { noConfirm: r0.json?.error, status: r.status, error: r.json?.error, lifecycle: r.json?.site?.lifecycle }; }); + if (pause.status === 200) await s.run('재개', async () => { const r = await alice.post(`/api/lifecycle/sites/${site.id}/resume`); return { status: r.status, lifecycle: r.json?.site?.lifecycle }; }, { soft: true }); + const mailLc = await s.run('메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공)', async () => { if (!state.standaloneMailId) return { skipped: true }; const r = await alice.get(`/api/lifecycle/mail/${state.standaloneMailId}`); return { status: r.status, sendReceive: r.json.view?.sendReceive, retentionUntil: !!r.json.view?.retentionUntil, deleteAfter: !!r.json.view?.deleteAfter, backupNote: r.json.view?.backupNote }; }); + report.flow(21, 'partial', { steps: s.list, note: live ? '중지·재개·생명주기 화면 확인' : '생명주기 화면·중지/해지 영향 안내·보존 정책 표시 확인. 운영 중 사이트의 실제 중지·해지는 배포 성공(흐름 6) 필요', blockedBy: live ? null : 6, evidence: { v, pause, mailLc } }); + report.flow(34, 'partial', { steps: s.list, note: '해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인)', blockedBy: live ? null : 6, evidence: { v, mailLc } }); + } catch (e) { report.flow(21, 'partial', { steps: s.list, note: e.message }); report.flow(34, 'partial', { steps: s.list, note: e.message }); } } + // ---- 19 사이트 수 반환 점검표 + { const s = steps(); try { + const ck = await s.run('§14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션)', async () => { const r = await alice.get(`/api/lifecycle/sites/${site.id}/site-count-return`); return { ok: r.json.checklist?.ok, blocking: r.json.checklist?.blocking, steps: r.json.checklist?.steps?.length }; }); + const keep = await s.run('메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙', async () => { const r = await alice.post(`/api/lifecycle/sites/${site.id}/site-count-return`, { mailChoice: 'keep', acknowledge: true, confirm: true }); return { status: r.status, error: r.json?.error, detail: r.json?.detail ? Object.keys(r.json.detail) : null }; }); + report.flow(19, 'partial', { steps: s.list, note: '반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인)', blockedBy: live ? null : 6, evidence: { ck, keep } }); + } catch (e) { report.flow(19, 'partial', { steps: s.list, note: e.message }); } } + // ---- 20 소유권 이전 (앨리스 팀 → 밥 개인) + { const s = steps(); try { + const bob = state.bob; if (!bob) throw new Error('밥 없음'); await bob.post('/api/groups/switch', { groupId: state.bobPersonal || (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal').id }); + const pv = await s.run('이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목)', async () => { const r = await alice.post('/api/transfer/preview', { siteIds: [site.id] }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); return { sites: r.json.scope?.sites?.length, domains: r.json.scope?.sites?.[0]?.domains?.length, siteMail: r.json.scope?.sites?.[0]?.siteMail?.length, needed: r.json.scope?.needed, excluded: r.json.scope?.excluded?.length, jobs: r.json.scope?.jobs }; }); + const tsite = (await alice.post('/api/sites', { name: '이전 대상 사이트', projectMode: 'none' })).json.site; + const rq = await s.run('요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림', async () => { const r = await alice.post('/api/transfer', { toEmail: 'bob@e2e.localhost', siteIds: [tsite.id], confirm: true }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const n = await bob.get('/api/notifications'); return { id: r.json.transfer.id, status: r.json.transfer.status, expiresAt: r.json.transfer.expires_at, bobNotified: (n.json.notifications || n.json.items || []).some((x) => /이전|소유권/.test(x.title)) }; }); + await s.run('(정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개)', async () => { const e = await alice.post(`/api/backup/sites/${site.id}/option/end`, { confirm: true }); const a = state.mailServiceId ? await alice.post(`/api/usage/allocate/mail_service:${state.mailServiceId}`, { resource: 'mailboxes', amount: 5 }) : null; return { endBackup: e.status, endError: e.json?.error, alloc: a?.status, allocError: a?.json?.error }; }, { soft: true }); + const acc = await s.run('받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내)', async () => { const r = await bob.post(`/api/transfer/${rq.id}/accept`, { confirm: true }); return { status: r.status, tstatus: r.json?.transfer?.status, hold: r.json?.transfer?.hold }; }); + if (acc.tstatus === 'on_hold') await s.run('받는 쪽 정리(실패 배포의 예약 반환) → 재확인 → 심사 단계', async () => { for (const bs of state.bobSites || []) await bob.post(`/api/sites/${bs.id}/cleanup`); const r = await bob.post(`/api/transfer/${rq.id}/recheck`); return { status: r.json?.transfer?.status, hold: r.json?.transfer?.hold }; }, { soft: true }); + const ap = await s.run('운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각', async () => { const bad = await env.relay('POST', `/transfer/${rq.id}/approve`, { operator: 'e2e', review: {}, confirm: { fromGroupId: tsite.group_id, toGroupId: 'x' } }); const to = (await alice.get(`/api/transfer/${rq.id}`)).json.transfer?.to_group_id; const ok = await env.relay('POST', `/transfer/${rq.id}/approve`, { operator: 'e2e', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: tsite.group_id, toGroupId: to } }); return { bad: bad.json?.error, ok: ok.status, status: ok.json?.transfer?.status, cutover: ok.json?.transfer?.billing_cutover_at, error: ok.json?.error }; }); + const after = await s.run('완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사', async () => { const a = await alice.get(`/api/sites/${tsite.id}`); const b = await bob.get(`/api/sites/${tsite.id}`); const au = await bob.get('/api/audit?action=ownership.complete'); return { aliceSees: a.status, bobSees: b.status, bobUsageSites: (await bob.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.allocated, audit: (au.json.items || [])[0]?.immutable }; }); + + report.flow(20, ap.status === 'completed' ? 'verified_local' : 'partial', { steps: s.list, note: ap.status === 'completed' ? '미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사' : '요청·수락·보류·심사 단계 확인(완료 여부는 증거 참조)', evidence: { pv, rq, acc, ap, after } }); + } catch (e) { report.flow(20, 'partial', { steps: s.list, note: e.message }); } } +} diff --git a/test/e2e/flows/80-hal.mjs b/test/e2e/flows/80-hal.mjs new file mode 100644 index 0000000..a36e649 --- /dev/null +++ b/test/e2e/flows/80-hal.mjs @@ -0,0 +1,67 @@ +/** 흐름 22·25·26·29·30·31: HAL 우선 배포 순서·모듈 배포(대상별 결과)·재해복구 훈련·HAL/포털 재시작 뒤 상태 유지·HAL 장애 중 포털 표시·긴급 복구 거부/정상 경로. */ +import { steps } from '../report.mjs'; +import { waitFor, sleep } from '../util.mjs'; +export const BUDGET_MS = 120000; +async function waitOpsJob(env, id, ms = 40000) { return waitFor(async () => { const r = await env.ops.get(`/ops/api/jobs/${id}`); const j = r.json?.job; return j && !['queued', 'running'].includes(j.status) && !(r.json.children?.children || []).some((c) => ['queued', 'running'].includes(c.status)) ? r.json : null; }, { timeoutMs: ms, intervalMs: 800 }); } +export async function run({ env, report, state }) { + const ctl = env.servers.control; const web = env.servers.web; const bk = env.servers.backup; + /** 진단: HAL 작업 엔진에 멈춘 작업이 있는지(앞선 흐름이 남긴 큐·실행 중 작업) */ + const stuck = async () => { const r = await env.ops.get('/ops/api/jobs?status=queued,running&limit=20'); return (r.json?.jobs || []).map((j) => ({ id: j.id, kind: j.kind, status: j.status, attempt: j.attempt, step: j.currentStep?.id, detail: (j.currentStep?.detail || j.waitReason || '').slice(0, 120), updatedAt: j.updatedAt })); }; + const stuckBefore = await stuck(); + // 앞 흐름이 남긴 queued HAL 작업(도메인 확인 재제출 결함)을 운영자 취소로 비워 모듈 배포가 큐에서 차례를 받게 한다. 실행 중(running) 작업은 끝날 때까지 기다린다(엔진이 한 번에 하나만 실행). + for (const j of stuckBefore.filter((x) => x.status === 'queued' && x.kind !== 'module.deploy')) await env.ops.post(`/ops/api/jobs/${j.id}/cancel`, {}); + await waitFor(async () => (await stuck()).filter((x) => x.status === 'running').length === 0 ? true : null, { timeoutMs: 45000, intervalMs: 1000 }); + report.flow(22, 'partial', { note: '시작 전 진단', evidence: { stuckBefore } }); + // ---- 22 HAL 우선 배포 + { const s = steps(); try { + const rel = await s.run('릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서', async () => { const r = await env.ops.get('/ops/api/releases'); const h = await env.ops.get('/api/health'); return { releases: r.json.releases.map((x) => x.version + ':' + x.status), running: h.json.bootstrap, order: r.json.deployOrder }; }); + const hf = await s.run('HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부', async () => { const r = await env.ops.post('/ops/api/modules/portal/deploy', { version: '0.1.2', serverIds: [ctl.serverId], nonce: 'e2e-hf' }); const j = await waitOpsJob(env, r.json.job.id, 20000); return { status: j?.job?.status, error: j?.job?.errorCode || j?.job?.error }; }); + const na = await s.run('승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시', async () => { const h = await env.ops.get('/api/health'); return { manualInstallLocked: h.json.bootstrap.manualInstallLocked, complete: h.json.bootstrap.complete }; }); + const dep = await s.run('HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인)', async () => { const r = await env.ops.post('/ops/api/modules/hal/deploy', { version: '0.1.1', serverIds: [ctl.serverId], nonce: 'e2e-hal' }); const j = await waitOpsJob(env, r.json.job.id, 10000); const x = await env.ops.get(`/ops/api/jobs/${r.json.job.id}`); return { status: x.json?.job?.status, steps: x.json?.job?.steps?.map((y) => y.id + ':' + y.state), error: x.json?.job?.error, children: x.json?.children?.children?.map((c) => c.status + ':' + (c.error || '').slice(0, 120)), agentJobs: x.json?.agentJobs?.map((a) => a.kind + ':' + a.status + ':' + (a.error || '').slice(0, 120)), timedOut: !!j?.timedOut }; }); + if (dep.timedOut) report.bug({ title: 'HAL 작업 엔진이 작업을 한 번에 하나씩만 실행(tick 이 execute 를 await)해 느린 작업이 큐 전체를 막고, module.deploy 부모가 자식 완료를 기다리면 자식이 실행될 수 없어 교착', file: 'lib/jobs.mjs (createJobEngine.start/tick) / hal/jobs.mjs (fanout 이 자식 완료를 폴링)', route: 'POST /ops/api/modules/:name/deploy', expected: '엔진은 running 수가 concurrency 미만이면 실행을 분리(detach)해 여러 작업을 동시에 진행해야 한다(부모가 자식을 기다려도 자식이 실행됨)', actual: dep, repro: '(1) Agent 가 수십 초 걸리는 domain.probe 가 queued 로 쌓이면 다른 모든 HAL 작업이 기다린다 (2) 큐가 비어도 모듈 배포는 부모 collect:running + 자식 queued 로 영원히 멈춘다(test/hal.test.mjs 는 pump() 로 tick 을 동시에 돌려 통과)', flows: [22, 30] }); + else if (dep.status !== 'done') report.bug({ title: '모듈 설치가 ARIWEB_SRV_ROOT 를 무시하고 절대 경로(/srv/ariweb/)에 쓰려 해 일반 계정·검증 환경에서 실패', file: 'hal/jobs.mjs (module.deploy.target: appDir 기본값) / agent/jobs/module.mjs', route: 'POST /ops/api/modules/:name/deploy', expected: 'Agent 가 payload.appDir 가 없거나 루트 override 가 있으면 roots.srv 아래에 설치', actual: dep, repro: 'ARIWEB_SRV_ROOT 를 둔 Agent 에 module.deploy → mkdir /srv/ariweb/hal 권한 오류', flows: [22, 30] }); + const diag = dep.status === 'done' ? null : { stuckNow: await stuck(), halLog: env.hal.proc.lines.slice(-6), ctlAgentLog: ctl.proc.lines.slice(-4) }; + report.flow(22, dep.status === 'done' ? 'verified_local' : 'failed', { steps: s.list, note: dep.status === 'done' ? 'HAL 우선 순서·릴리스 승인·모듈 설치' : 'HAL 우선 순서(hal_first 거부)·릴리스 승인·부트스트랩 잠김은 확인. 모듈 배포 작업은 완료되지 않음(진단 증거 참조)', evidence: { rel, hf, na, dep, diag } }); + } catch (e) { report.flow(22, 'failed', { steps: s.list, note: e.message }); } } + // ---- 30 일부 서버 실패·대상별 결과 + { const s = steps(); try { + const d = await s.run('agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed)', async () => { const r = await env.ops.post('/ops/api/modules/agent/deploy', { version: '0.1.1', serverIds: [web.serverId, bk.serverId, 'srv_missing_e2e'], nonce: 'e2e-partial' }); const j = await waitOpsJob(env, r.json.job.id, 10000); const x = await env.ops.get(`/ops/api/jobs/${r.json.job.id}`); return { status: x.json?.job?.status, errorCode: x.json?.job?.errorCode, overall: x.json?.children?.overall, children: x.json?.children?.children?.map((c) => (c.subject?.serverId || '') + ':' + c.status + ':' + (c.error || '').slice(0, 80)), timedOut: !!j?.timedOut }; }); + const h = await s.run('모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업', async () => { const r = await env.ops.post('/ops/api/modules/agent/health', { serverIds: [web.serverId] }); const j = await waitOpsJob(env, r.json.job.id, 5000); const ov = await env.ops.get('/ops/api/overview'); const rc = await env.ops.post('/ops/api/modules/reconcile', { module: 'agent' }); return { health: j?.job?.status || 'timeout', drift: ov.json.drift?.length, reconcile: rc.status, overviewComponents: ov.json.components?.map((c) => c.name + ':' + c.status) }; }); + const rb = await s.run('되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부', async () => { const a = await env.ops.post('/ops/api/modules/agent/rollback', { serverIds: [web.serverId] }); const b = await env.ops.post('/ops/api/modules/agent/rollback', { serverIds: [web.serverId], confirmImpact: true, confirmIdentifiers: ['wrong'] }); return { step1: a.json?.error, step2: b.json?.error }; }); + report.flow(30, 'failed', { steps: s.list, note: '대상별 자식 작업 생성·되돌리기 3단계 확인 거부는 확인. 일부 실패 요약·건강 확인·drift 조정은 작업 엔진 교착(흐름 22 결함)으로 완료되지 않음', blockedBy: 22, evidence: { d, h, rb } }); + } catch (e) { report.flow(30, 'partial', { steps: s.list, note: e.message }); } } + // ---- 25 재해복구 + { const s = steps(); try { + const r0 = await s.run('설정 전 준비 상태(미설정 항목)', async () => (await env.ops.get('/ops/api/dr')).json.readiness); + const cfg = await s.run('RTO·RPO·사본 주기·보존 개수·외부 보관 위치 설정', async () => { const r = await env.ops.put('/ops/api/dr/config', { rto_minutes: 60, rpo_minutes: 30, snapshot_interval_minutes: 60, retention_count: 3, external_store: `${env.base}/dr-external` }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return r.json.config; }); + const snap = await s.run('복구 사본 생성·검증·외부 보관(vault.key 제외)', async () => { const r = await env.ops.post('/ops/api/dr/snapshots'); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); return { verify: r.json.snapshot.verify_state, external: !!r.json.snapshot.external_copied_at, sha: !!r.json.snapshot.sha256 }; }); + const rs = await s.run('복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료', async () => { const sid = (await env.ops.get('/ops/api/dr')).json.snapshots[0].id; const rq = await env.ops.post('/ops/api/dr/restores', { snapshotId: sid, note: 'e2e 훈련' }); const id = rq.json.restore.id; const early = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: true, confirmText: '기존 제어 위치를 중지했습니다' }); await env.ops.post(`/ops/api/dr/restores/${id}/approve`); const noStop = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: false }); const st = await env.ops.post(`/ops/api/dr/restores/${id}/start`, { oldControlStopped: true, confirmText: '기존 제어 위치를 중지했습니다' }); const inc = await env.ops.post(`/ops/api/dr/restores/${id}/finish`, { checks: { operatorLogin: true } }); const fin = await env.ops.post(`/ops/api/dr/restores/${id}/finish`, { checks: { operatorLogin: true, halControl: true, https: true, customerService: true } }); return { early: early.json?.error, noStop: noStop.json?.error, command: !!st.json?.command, incomplete: inc.json?.error, final: fin.json?.restore?.status }; }); + const drill = await s.run('복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료', async () => { const sid = (await env.ops.get('/ops/api/dr')).json.snapshots[0].id; const d1 = await env.ops.post('/ops/api/dr/drills', { snapshotId: sid }); const f1 = await env.ops.post(`/ops/api/dr/drills/${d1.json.drill.id}/finish`, { steps: { hal: true, operatorLogin: true, vault: true, certBase: true, httpsCustomer: false }, measuredRtoMinutes: 20, measuredRpoMinutes: 10 }); const d2 = await env.ops.post('/ops/api/dr/drills', {}); const f2 = await env.ops.post(`/ops/api/dr/drills/${d2.json.drill.id}/finish`, { steps: { hal: true, operatorLogin: true, vault: true, certBase: true, httpsCustomer: true }, measuredRtoMinutes: 20, measuredRpoMinutes: 10 }); const rd = (await env.ops.get('/ops/api/dr')).json.readiness; return { first: f1.json?.drill?.result, second: f2.json?.drill?.result, ready: rd.ready, measured: rd.lastDrill ? { rto: rd.lastDrill.measured_rto_minutes, rpo: rd.lastDrill.measured_rpo_minutes } : null }; }); + report.needsInfra('재해복구의 실제 복원(hal-restore.sh 로 다른 호스트에 HAL 재기동)·실제 운영자 로그인·HTTPS·고객 서비스 외부 확인은 실 서버가 필요하다(흐름 25 기록·훈련 절차만 로컬 확인)'); + report.flow(25, 'partial', { steps: s.list, note: 'DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요', evidence: { r0, cfg, snap, rs, drill } }); + } catch (e) { report.flow(25, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '재해복구 운영 API 흐름 실패', file: 'hal/dr.mjs', route: '/ops/api/dr/*', expected: '설정→사본→복원 기록→훈련', actual: e.message, repro: '80-hal.mjs 흐름 25', flows: [25] }); } } + // ---- 31 긴급 복구 + { const s = steps(); try { + const healthy = await s.run('HAL 정상 상태에서 선언 거부(hal_healthy)', async () => { const r = await env.ops.post('/ops/api/emergency', { incidentKey: 'INC-E2E', reason: 'HAL DB 손상으로 작업 엔진 사용 불가', scope: ['hal', 'hal_data'] }); return r.json?.error; }); + const sess = await s.run('critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)', async () => { await env.ops.post('/ops/api/incidents', { key: 'INC-E2E', severity: 'critical', title: 'HAL 장애(e2e)', customerImpact: '없음' }); const d = await env.ops.post('/ops/api/emergency', { incidentKey: 'INC-E2E', reason: 'HAL DB 손상으로 작업 엔진 사용 불가', scope: ['hal', 'hal_data'] }); if (d.status !== 200) throw new Error(d.text.slice(0, 200)); const id = d.json.session.id; const noCred = await env.ops.post(`/ops/api/emergency/${id}/credential`); const wrongStep = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal'] }); await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 1, impactText: 'HAL 제어 평면 중단, 고객 서비스는 유지' }); const badIds = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal'] }); await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 2, identifiers: ['hal_data', 'hal'] }); const badText = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 3, finalConfirm: '아무거나', revertible: true }); const ok = await env.ops.post(`/ops/api/emergency/${id}/approve`, { step: 3, finalConfirm: '복구 실행 INC-E2E', revertible: true }); const cred = await env.ops.post(`/ops/api/emergency/${id}/credential`); return { id, noCred: noCred.json?.error, wrongStep: wrongStep.json?.error, badIds: badIds.json?.error, badText: badText.json?.error, approved: ok.json?.session?.status, approvals: ok.json?.session?.approvals?.length, credential: cred.json?.credential, expiresAt: cred.json?.expiresAt }; }); + const perf = await s.run('일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료', async () => { const id = sess.id; const c = sess.credential; const forb = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'module.deploy' }); const oos = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'cert.reissue_internal' }); const bad = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: 'bad', action: 'hal.restart' }); const ok = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'vault.verify' }); const v = await env.ops.post(`/ops/api/emergency/${id}/verify`, { verify: { vaultOk: true } }); const after = await env.ops.post(`/ops/api/emergency/${id}/perform`, { credential: c, action: 'vault.verify' }); const rc = await env.ops.post(`/ops/api/emergency/${id}/reconcile`); const cl = await env.ops.post(`/ops/api/emergency/${id}/close`, { note: 'e2e' }); return { forbidden: forb.json?.error, outOfScope: oos.json?.error, badCred: bad.json?.error, performed: ok.status, revoked: !!v.json?.session?.credential_revoked_at, afterRevoke: after.json?.error, reconcile: rc.status, closed: cl.json?.session?.status }; }); + report.flow(31, 'partial', { steps: s.list, note: '정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인)', evidence: { healthy, sess: { ...sess, credential: undefined }, perf } }); + } catch (e) { report.flow(31, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '긴급 복구 흐름 실패', file: 'hal/emergency.mjs', route: '/ops/api/emergency/*', expected: '3단계 승인·거부 규칙', actual: e.message, repro: '80-hal.mjs 흐름 31', flows: [31] }); } } + // ---- 29 HAL 장애 중 포털 + { const s = steps(); const alice = state.alice; try { + if (!alice || !state.site) throw new Error('고객 세션/사이트 없음'); + const client = alice; const fresh = (await client.post('/api/sites', { name: 'HAL 장애 확인용', projectMode: 'new', buildMode: 'manual', deployMode: 'managed' })).json.site; await env.wm.addVersion(fresh.webmaker_project_id); const sid = fresh.id; + env.hal.proc.kill('SIGKILL'); await sleep(800); + const during = await s.run('HAL 중단 중: 기존 조회(사이트 목록·개요)는 제공, 배포 전 확인/배포는 정보 확인 지연·차단', async () => { const l = await client.get('/api/sites'); const ov = await client.get(`/api/sites/${sid}/overview`); const pc = await client.get(`/api/sites/${sid}/precheck`); const dep = await client.post(`/api/sites/${sid}/deploy`, {}); return { list: l.status, overview: ov.status, precheck: { status: pc.status, blocks: (pc.json?.precheck?.items || []).filter((i) => i.state === 'block').map((i) => i.id + ':' + (i.text || '').slice(0, 40)) }, deploy: { status: dep.status, error: dep.json?.error, message: dep.json?.message } }; }); + const dash = await s.run('대시보드/알림에 중앙 상태 확인 지연 표시(가능하면)', async () => { const d = await client.get('/api/dashboard'); return { status: d.status, halState: d.json?.summary?.hal || d.json?.platform || d.json?.stale || null }; }, { soft: true }); + const back = await s.run('HAL 재기동(같은 호스트의 죽은 인스턴스 임대 이어받기) → 서버·Agent 상태 복귀', async () => { const h = await env.hal.restart(); if (!h || h.timedOut) { const h2 = await env.hal.restart({ takeover: true }); if (!h2 || h2.timedOut) throw new Error('HAL 재기동 실패: ' + env.hal.proc.lines.slice(-4).join(' | ')); return { takeoverNeeded: true, bootstrap: h2.bootstrap?.complete }; } await env.loginOps(); const srv = await waitFor(async () => { const r = await env.ops.get('/ops/api/servers'); return r.json?.servers?.filter((x) => x.status === 'ready').length >= 4 ? r : null; }, { timeoutMs: 15000, intervalMs: 500 }); return { takeoverNeeded: false, bootstrap: h.bootstrap?.complete, readyServers: srv.json?.servers?.filter((x) => x.status === 'ready').length }; }); + report.flow(29, 'verified_local', { steps: s.list, note: 'HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀', evidence: { during, dash, back } }); + } catch (e) { report.flow(29, 'partial', { steps: s.list, note: e.message }); try { if (env.hal.proc.exited) await env.hal.restart({ takeover: true }).then(() => env.loginOps()); } catch (_) {} } } + // ---- 26 재시작 뒤 상태 유지 + { const s = steps(); const alice = state.alice; try { + const before = await s.run('재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록', async () => { const m = state.mailServiceId ? await alice.get(`/api/mail/services/${state.mailServiceId}/job`) : null; const j = await env.ops.get('/ops/api/jobs?limit=50'); return { mailJob: m?.json?.job ? { id: m.json.job.id, status: m.json.job.status } : null, halJobs: j.json?.jobs?.length }; }); + const pr = await s.run('포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지', async () => { await env.portal.restart(); const me = await alice.get('/api/auth/me'); const m = state.mailServiceId ? await alice.get(`/api/mail/services/${state.mailServiceId}/job`) : null; return { sessionKept: !!me.json?.user, mailJob: m?.json?.job ? { id: m.json.job.id, status: m.json.job.status } : null, same: m?.json?.job?.id === before.mailJob?.id }; }); + const hr = await s.run('HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지', async () => { await env.hal.restart(); const me = await env.ops.get('/ops/api/me'); const j = await env.ops.get('/ops/api/jobs?limit=50'); return { opsSessionKept: !!me.json?.operator, halJobs: j.json?.jobs?.length, sameCount: j.json?.jobs?.length === before.halJobs }; }); + report.flow(26, pr.sessionKept && pr.same ? 'verified_local' : 'partial', { steps: s.list, note: '포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요', evidence: { before, pr, hr } }); + } catch (e) { report.flow(26, 'partial', { steps: s.list, note: e.message }); } } +} diff --git a/test/e2e/report.mjs b/test/e2e/report.mjs new file mode 100644 index 0000000..27b8ba4 --- /dev/null +++ b/test/e2e/report.mjs @@ -0,0 +1,61 @@ +/** + * test/e2e/report.mjs — 흐름별 판정·증거를 모아 docs/verification/local-e2e-<날짜>.md / .json 으로 쓴다. + * + * 판정(verdict) + * verified_local 로컬 환경(실제 HAL·포털·Agent 프로세스 + 가짜 Webmaker/AriMail/메일)에서 끝까지 확인됨 + * partial 일부 단계만 확인됨(나머지는 실제 인프라가 필요하거나 선행 흐름의 결함으로 막힘) + * not_verifiable_locally 로컬에서 확인할 수 없음(실 DNS·메일 왕복·실서버 등) + * failed 실제 결함으로 보이는 실패(재현 정보를 bugs 에 기록) + * 증거(evidence)에는 HTTP 상태·최종 화면 상태·작업 단계만 남기고 비밀값은 넣지 않는다(lib/redact). + */ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { redact } from '../../lib/redact.mjs'; + +export const FLOWS = [ + [1, '신규 고객 가입과 이메일 확인'], [2, '개인 사용자 그룹 생성과 다른 사용자 초대'], [3, '상품 주문, 결제 확인, 권리 활성화'], [4, '초안 사이트 생성'], [5, 'Webmaker에서 페이지 제작'], + [6, '관리형 배포'], [7, '도메인 연결과 HTTPS 발급'], [8, '외부 주소에서 사이트 확인'], [9, '사이트 정보와 접속·사용량 통계 확인'], [10, '사이트 대표 메일과 메일함 생성'], + [11, '외부 송신, 수신, 회신 확인'], [12, '독립 메일 서비스 생성'], [13, '백업 옵션 구매와 첫 백업 확인'], [14, '백업 복원과 외부 사이트 확인'], [15, '관리형 사이트 서버 이전과 외부 확인'], + [16, '외부 배포 사이트 생성과 배포'], [17, '사용량 경고와 한도 동작 확인'], [18, '상품 축소 보류와 정리 후 적용'], [19, '사이트 수 반환과 대표 메일의 독립 전환'], [20, '사이트 소유권 이전'], + [21, '중지, 해지, 보존 예정일 표시'], [22, 'HAL 우선 배포 후 HAL을 통한 전체 모듈 배포'], [23, '실패 작업의 재시도와 되돌리기'], [24, '운영자 승인, 감사 기록, 고객 알림의 일치'], [25, 'HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련'], + [26, '새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지'], [27, '마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공'], [28, '외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리'], [29, 'HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시'], [30, '모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정'], + [31, '긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구'], [32, 'Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료'], [33, '메일 서비스의 사이트 대표→독립 전환 후 항목 유지'], [34, '일시 중지·해지 예약·보존·삭제 단계의 표시 일치'], +]; +const LABEL = { verified_local: '로컬 확인', partial: '부분 확인', not_verifiable_locally: '로컬 확인 불가', failed: '실패(결함)' }; + +export function createReport({ startedAt = new Date() } = {}) { + const flows = new Map(); const bugs = []; const infra = new Set(); const env = {}; + const api = { + env, + /** 흐름 판정 기록(같은 번호를 다시 적으면 덮어쓴다 — 나중 판정이 더 정확) */ + flow(no, verdict, { evidence = {}, note = '', blockedBy = null, steps = [] } = {}) { flows.set(no, { no, title: (FLOWS.find((f) => f[0] === no) || [])[1] || '', verdict, note, blockedBy, steps, evidence: redact(evidence) }); return api; }, + /** 결함 기록: 정확한 재현 정보(파일·경로·기대·실제) */ + bug({ title, file, route = '', expected, actual, repro = '', flows: fl = [] }) { if (!bugs.some((b) => b.title === title)) bugs.push({ title, file, route, expected, actual: redact(actual), repro, flows: fl }); return api; }, + /** 실제 인프라가 필요한 항목 */ + needsInfra(text) { infra.add(text); return api; }, + bugs, infra, flows, + summary() { const c = { verified_local: 0, partial: 0, not_verifiable_locally: 0, failed: 0, unrecorded: 0 }; for (const [no] of FLOWS) { const f = flows.get(no); if (!f) c.unrecorded++; else c[f.verdict] = (c[f.verdict] || 0) + 1; } return c; }, + write(dir, { date = startedAt } = {}) { + mkdirSync(dir, { recursive: true }); const stamp = date.toISOString().slice(0, 10); const sum = api.summary(); + const rows = FLOWS.map(([no, title]) => { const f = flows.get(no); return f ? f : { no, title, verdict: 'unrecorded', note: '하네스가 이 흐름을 실행하지 못함(앞선 오류)', evidence: {}, steps: [] }; }); + const json = { generatedAt: new Date().toISOString(), startedAt: startedAt.toISOString(), env, summary: sum, flows: rows, bugs, needsInfrastructure: [...infra] }; + writeFileSync(join(dir, `local-e2e-${stamp}.json`), JSON.stringify(json, null, 1)); + let md = `# 로컬 종단 간 검증 기록 — ${stamp}\n\n생성: ${json.generatedAt} · \`node --no-warnings test/e2e.test.mjs\` (test/e2e/)\n\n`; + md += `실행 환경: 실제 HAL·고객 포털·Node Agent(명령 stub 모드) 프로세스 ${env.agents || 0}대 + 가짜 Webmaker/AriMail/발신 메일 서버(127.0.0.1). 실 DNS·실 메일 왕복·실 서버는 포함하지 않는다.\n\n`; + md += `## 요약\n\n| 판정 | 수 |\n|---|---|\n` + Object.entries(sum).map(([k, v]) => `| ${LABEL[k] || k} | ${v} |`).join('\n') + '\n\n'; + md += `## 흐름별 판정(Release 0.1.1 완료 조건 34개)\n\n| # | 흐름 | 판정 | 확인 내용 / 비고 |\n|---|---|---|---|\n`; + for (const f of rows) md += `| ${f.no} | ${f.title} | ${LABEL[f.verdict] || f.verdict} | ${(f.note || '').replace(/\|/g, '/')}${f.blockedBy ? ` (선행 흐름 ${f.blockedBy} 에 막힘)` : ''} |\n`; + md += `\n## 발견한 결함(재현 정보)\n\n`; if (!bugs.length) md += '없음\n'; for (const b of bugs) md += `- **${b.title}** — \`${b.file}\`${b.route ? ` (${b.route})` : ''}\n - 기대: ${b.expected}\n - 실제: ${typeof b.actual === 'string' ? b.actual : JSON.stringify(b.actual)}\n - 재현: ${b.repro}${b.flows.length ? ` · 영향 흐름 ${b.flows.join(', ')}` : ''}\n`; + md += `\n## 실제 인프라가 필요한 항목\n\n`; for (const i of infra) md += `- ${i}\n`; + md += `\n## 흐름별 증거(요약)\n\n`; for (const f of rows) { if (!f.steps?.length && !Object.keys(f.evidence || {}).length) continue; md += `### ${f.no}. ${f.title}\n\n`; for (const s of f.steps || []) md += `- ${s.ok === false ? '✘' : '✔'} ${s.name}${s.detail ? ` — ${typeof s.detail === 'string' ? s.detail : JSON.stringify(s.detail).slice(0, 300)}` : ''}\n`; if (Object.keys(f.evidence || {}).length) md += '\n```json\n' + JSON.stringify(f.evidence, null, 1).slice(0, 1800) + '\n```\n'; md += '\n'; } + writeFileSync(join(dir, `local-e2e-${stamp}.md`), md); + return { md: join(dir, `local-e2e-${stamp}.md`), json: join(dir, `local-e2e-${stamp}.json`), summary: sum }; + }, + }; + return api; +} +/** 흐름 안에서 단계별 증거를 모으는 도우미: const s = steps(); await s.run('이름', fn) → 결과 기록(예외는 ok:false 로 남기고 다시 던진다) */ +export function steps() { + const list = []; + return { list, async run(name, fn, { soft = false } = {}) { try { const r = await fn(); list.push({ name, ok: true, detail: r === undefined ? undefined : (typeof r === 'string' ? r : redact(r)) }); return r; } catch (e) { list.push({ name, ok: false, detail: e && e.message ? String(e.message).slice(0, 300) : String(e) }); if (soft) return null; throw e; } }, note(name, detail) { list.push({ name, ok: true, detail }); } }; +} diff --git a/test/e2e/util.mjs b/test/e2e/util.mjs new file mode 100644 index 0000000..5e88f6e --- /dev/null +++ b/test/e2e/util.mjs @@ -0,0 +1,86 @@ +/** + * test/e2e/util.mjs — 로컬 종단 간(e2e) 검증 하네스 공통 도우미. + * + * - freePort() : 127.0.0.1 의 빈 포트 하나 + * - waitFor(fn, ms) : 조건이 참이 될 때까지 폴링(프로세스 기동·작업 완료 대기) + * - makeClient(base) : 쿠키 항아리·CSRF 헤더를 가진 작은 HTTP 클라이언트(고객 포털·운영자 콘솔 공용) + * - signedClient(...) : lib/serviceauth 서명 호출(서비스 간 API 를 하네스가 직접 부를 때) + * - tarGz(dir, file) : 디렉터리를 tar.gz 로(릴리스 artifact·Webmaker 저장본 흉내) + * - spawnNode(args, env): 저장소의 node 프로세스를 띄우고 stdout/stderr 를 모아 둔다(보고서 증거용) + */ +import net from 'node:net'; +import { spawn, execFileSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { signHeaders } from '../../lib/serviceauth.mjs'; + +export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 127.0.0.1 의 빈 포트 */ +export function freePort() { return new Promise((resolve, reject) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); s.on('error', reject); }); } + +/** 조건이 참(또는 값)이 될 때까지 기다린다. 시간이 다 되면 마지막 값을 돌려준다(예외 대신 — 보고서가 상태를 적는다). */ +export async function waitFor(fn, { timeoutMs = 30000, intervalMs = 300, label = '' } = {}) { + const t0 = Date.now(); let last; + while (Date.now() - t0 < timeoutMs) { try { last = await fn(); if (last) return last; } catch (e) { last = { error: e.message, code: e.code }; } await sleep(intervalMs); } + return last && typeof last === 'object' ? { ...last, timedOut: true, label } : { timedOut: true, label, last }; +} + +/** + * 쿠키·CSRF 를 기억하는 HTTP 클라이언트. 응답 { status, json, text, headers, cookies }. + * 같은 base 의 Set-Cookie 를 항아리에 모으고 다음 요청에 보낸다. csrf 는 호출자가 setCsrf 로 넣는다(포털: /api/auth/csrf, HAL: 로그인 응답). + */ +export function makeClient(base, { ip = null } = {}) { + const jar = new Map(); let csrf = ''; const log = []; + function cookieHeader() { return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); } + async function req(method, path, body, headers = {}) { + const url = base.replace(/\/$/, '') + path; + const h = { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': 'ariweb-e2e/0.1', ...(csrf ? { 'X-CSRF': csrf } : {}), ...(ip ? { 'X-Real-IP': ip } : {}), ...headers }; + const ck = cookieHeader(); if (ck) h.Cookie = ck; + let r; try { r = await fetch(url, { method, headers: h, body: body === undefined ? undefined : (typeof body === 'string' ? body : JSON.stringify(body)), redirect: 'manual', signal: AbortSignal.timeout(30000) }); } + catch (e) { const out = { status: 0, error: e.message, json: null, text: '', headers: {}, cookies: {} }; log.push({ method, path, status: 0, error: e.message }); return out; } + const setCookies = typeof r.headers.getSetCookie === 'function' ? r.headers.getSetCookie() : [].concat(r.headers.get('set-cookie') || []); + const cookies = {}; + for (const c of setCookies) { const [kv, ...attrs] = c.split(';'); const i = kv.indexOf('='); const k = kv.slice(0, i).trim(); const v = kv.slice(i + 1).trim(); cookies[k] = decodeURIComponent(v); const maxAge = attrs.map((a) => a.trim()).find((a) => /^Max-Age=/i.test(a)); if (maxAge && Number(maxAge.split('=')[1]) <= 0) jar.delete(k); else jar.set(k, v); } + const text = await r.text(); let json = null; try { json = JSON.parse(text); } catch (_) {} + const out = { status: r.status, json, text, headers: Object.fromEntries(r.headers.entries()), cookies }; + log.push({ method, path, status: r.status, error: json && json.ok === false ? json.error : undefined }); + return out; + } + return { base, req, get: (p, h) => req('GET', p, undefined, h), post: (p, b, h) => req('POST', p, b ?? {}, h), put: (p, b, h) => req('PUT', p, b ?? {}, h), patch: (p, b, h) => req('PATCH', p, b ?? {}, h), del: (p, b, h) => req('DELETE', p, b, h), setCsrf: (v) => { csrf = v; }, cookie: (k) => jar.get(k), setCookie: (k, v) => jar.set(k, v), clear: () => { jar.clear(); csrf = ''; }, log }; +} + +/** 서비스 간 서명 호출(keyId·secret 로 lib/serviceauth 헤더를 만든다). */ +export function signedClient(base, keyId, secret, extraHeaders = {}) { + return async function call(method, path, body) { + const text = body === undefined ? undefined : JSON.stringify(body); + const h = { ...signHeaders(keyId, secret, method, path, text), ...extraHeaders }; + let r; try { r = await fetch(base.replace(/\/$/, '') + path, { method, headers: h, body: text, signal: AbortSignal.timeout(30000) }); } catch (e) { return { status: 0, error: e.message, json: null }; } + const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch (_) {} + return { status: r.status, json, text: t }; + }; +} + +/** 디렉터리를 tar.gz 로 묶는다(상대 경로 ./ 기준). */ +export function tarGz(dir, file) { execFileSync('tar', ['-czf', file, '-C', dir, '.']); return file; } + +/** 저장소의 node 스크립트 실행(동기, 출력 반환) — HAL CLI 등 */ +export function runNode(args, env = {}, opts = {}) { + const r = execFileSync(process.execPath, ['--no-warnings', ...args], { cwd: ROOT, env: { ...process.env, ...env }, encoding: 'utf8', timeout: opts.timeoutMs || 60000, stdio: ['ignore', 'pipe', 'pipe'] }); + return r; +} + +/** 장기 실행 node 프로세스 띄우기. 출력은 메모리에 모은다(마지막 400줄). */ +export function spawnNode(name, args, env = {}) { + const p = spawn(process.execPath, ['--no-warnings', ...args], { cwd: ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }); + const lines = []; const push = (s) => { for (const l of String(s).split('\n')) if (l.trim()) { lines.push(l); if (lines.length > 400) lines.shift(); } }; + p.stdout.on('data', push); p.stderr.on('data', push); + const proc = { name, p, args, env, lines, exited: null }; + p.on('exit', (code, signal) => { proc.exited = { code, signal, at: new Date().toISOString() }; }); + proc.kill = (sig = 'SIGTERM') => { try { p.kill(sig); } catch (_) {} }; + proc.waitExit = (ms = 5000) => waitFor(() => proc.exited, { timeoutMs: ms, intervalMs: 100 }); + return proc; +} +/** 민감 정보를 빼고 증거로 남길 짧은 JSON */ +export function brief(v, max = 600) { try { const s = JSON.stringify(v); return s.length > max ? s.slice(0, max) + '…' : s; } catch (_) { return String(v); } }