From 95dc76f15093f3f34772bed9169a71edd8c81cfd Mon Sep 17 00:00:00 2001 From: zio Date: Sat, 11 Jul 2026 19:54:35 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20GUARDiA=20=EC=9E=AC=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=20=ED=9D=A1=EC=88=98=20=E2=80=94=20?= =?UTF-8?q?=EC=8A=A4=EB=AA=A8=ED=81=AC=20=EB=9F=AC=EB=84=88=C2=B7push?= =?UTF-8?q?=C2=B7=EC=A0=95=ED=95=A9/=EB=B3=B4=EC=95=88/=EC=9A=B4=EC=98=81?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/test/kintex_smoke_test.py: 8021 스모크/회귀 러너(run_full_test 패턴, env-only 시크릿, 비파괴 프로브, CI 게이트) - scripts/push_kintex.py: push_any_repo 경량 이식(자격증명 env-only) - docs/GUARDIA_ALIGNMENT.md: 표준 프레임워크 정합 매핑 - docs/SECURITY.md: 보안 불변(외부API·자격증명·워터마크·root 예외) - docs/OPS_RUNBOOK.md: 헬스체크·재기동·롤백 런북 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/GUARDIA_ALIGNMENT.md | 102 ++++++++++++ docs/OPS_RUNBOOK.md | 135 ++++++++++++++++ docs/SECURITY.md | 70 +++++++++ scripts/push_kintex.py | 197 +++++++++++++++++++++++ tools/test/README.md | 39 +++++ tools/test/kintex_smoke_test.py | 271 ++++++++++++++++++++++++++++++++ 6 files changed, 814 insertions(+) create mode 100644 docs/GUARDIA_ALIGNMENT.md create mode 100644 docs/OPS_RUNBOOK.md create mode 100644 docs/SECURITY.md create mode 100644 scripts/push_kintex.py create mode 100644 tools/test/README.md create mode 100644 tools/test/kintex_smoke_test.py diff --git a/docs/GUARDIA_ALIGNMENT.md b/docs/GUARDIA_ALIGNMENT.md new file mode 100644 index 0000000..d3b5a49 --- /dev/null +++ b/docs/GUARDIA_ALIGNMENT.md @@ -0,0 +1,102 @@ +# KINTEX ↔ GUARDiA 표준 프레임워크 정합 매핑 + +> 킨텍스 자동전시시스템이 **GUARDiA 표준 프레임워크(UIMS/WISE 기준)** 를 어떻게 준수·차용·차이 처리하는지의 단일 참조. +> 정본 표준: `workspace/_framework/GUARDIA_STANDARD_FRAMEWORK.md` · WISE 적용 명세: `workspace/_framework/WISE_APPLY_SPEC.md` (둘 다 읽기 전용). +> 이 문서는 킨텍스 관점의 **매핑·갭**만 기록한다(표준 원문 복제 금지). + +--- + +## 0. 요약 + +| 축 | 표준(GUARDiA/UIMS) | KINTEX 상태 | +|----|--------------------|-------------| +| 스택 | Spring Boot 3.5 Java17 + React18/19 Vite + MyBatis + PostgreSQL | ✅ 준수 (+ PostGIS·Redis·나노바나나 Python 워커 = 도메인 확장) | +| 인증 | JWT + 2FA(OTP) + 로그인 실패 잠금 + admin 비번 env | ✅ 준수 (행사 단위 RBAC 로 역할 모델 확장) | +| 공통 모듈 | worklog·schedule·message·stats·system·notice·… | ✅ WISE 이식 (`work/*`·`system/*` 패키지) | +| 디자인 | WISE 토큰·선(stroke) SVG·chief-designer 리드 | ✅ 준수 | +| AI | 프로바이더 선택형 + AiTextRouter 폴백 + DuckDB 학습 | ◐ Claude 기본 + AiConfig 전환(설계) — 나노바나나(Gemini)는 **승인된 이미지 예외** | +| 보안 | 외부 API 금지(anthropic 예외)·AES-256-GCM·감사로그 | ✅ 준수 (+ Gemini 이미지 예외·AI 이미지 워터마크 강제) | +| 배포 | workspace→repos(fresh)→Gitea→webhook→systemd→nginx | ✅ 준수 (분리형: 프론트 dist→nginx / 백엔드 jar→8021) | +| 스키마 | Flyway/sql.init 멱등·mode·누출차단 | ◐ Flyway 사용 — GUARDiA sql.init 갭은 §4 참조 | + +범례: ✅ 완전 준수 · ◐ 부분/설계 · ✕ 미준수. + +--- + +## 1. 스택 정합 + +- **일치**: Spring Boot 3.x(Java 17)·MyBatis·React(Vite/TS)·PostgreSQL·단일 Gitea repo·systemd·nginx. +- **킨텍스 확장(표준 상위집합)**: + - **PostGIS** — 부스 폴리곤·트렌치 포인트·배선 LineString 공간 연산(표준엔 없음, 도메인 필수). + - **Redis 작업 큐** — RenderJob·서류·알림 비동기(`kintex:renderjob:queue`). + - **나노바나나 Python 워커** — Gemini 이미지 생성 사이드카(별도 systemd `kintex-nanobanana.service`). 백엔드는 큐 발행만, 키는 워커 env 전용. +- **패키징 차이**: 표준은 "단일 jar(프론트→백엔드 static 번들)". 킨텍스는 **역할별 프론트 번들 분리**(PLANNING §2-1)라 nginx 정적 서빙 + 백엔드 API 분리를 기본으로 한다. → 표준의 *의도*(무중단·헬스게이트 배포)는 유지, 물리 패키징만 분리형. + +## 2. 인증 정합 (JWT + 2FA) + +- **일치**: `Authorization: Bearer `(HS256), 2단계 로그인(`/api/auth/login` → `verify-otp`), admin 비번 env(`ADMIN_PASSWORD_ENC`+`ADMIN_KEY_FILE`) 재시드, 로그인 실패 잠금. +- **확장**: RBAC 가 전역 역할이 아니라 **행사(event) 스코프 역할**(`ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER`) — JWT 클레임 `roles`=eventId→역할, `hm`=홀매니저. `/api/admin/**`=ADMIN 게이트는 표준 그대로. +- **공개 경로**: `GET /health`·`POST /api/auth/login`·`/ws/**`·`POST /api/internal/render/callback`(워커 토큰). 그 외 인증 필요. + +## 3. 공통 모듈 정합 (WISE 이식) + +킨텍스 백엔드 `work/*`·`system/*` 패키지에 WISE 공통 레이어를 이식했다(컨트롤러 실측): + +| 표준 모듈 | KINTEX 경로 | +|-----------|-------------| +| worklog | `work/worklog` (`/api/work/...`) | +| schedule | `work/schedule` | +| message | `work/message` | +| stats | `work/stats` (`GET /api/work/stats/worklog`) | +| notice | `work/notice` (`/api/work/notices`) | +| opinion | `work/opinion` | +| search | `work/search` | +| meeting | `work/meeting` | +| report | `work/report` | +| notification | `work/notification` (`/api/work/notifications/unread-count`) | +| system(사용자·역할·공통코드·메뉴·설정·감사) | `system/*` + `common/audit` (`/api/common/**`·`/api/admin/**`) | + +이식 하네스: 신규 공통 모듈 필요 시 `uiws-port-orchestrator`(기존 솔루션 전파) 패턴 준용. 킨텍스는 `kintex-common-dev` 에이전트가 담당. + +## 4. 스키마 무결성 — Flyway 갭 (GUARDiA schema-integrity 패턴 요약) + +GUARDiA 다수 솔루션은 `sql.init.mode=never` + schema.sql 후행 확장 때문에 **누락 테이블**(`relation "x" does not exist`) 500 장애를 겪었고, 검증된 수복 패턴은: +1. **시드 멱등화** — 유니크 인덱스로 `INSERT ... ON CONFLICT DO NOTHING` (재적용 안전). +2. `sql.init.mode=always` + `continue-on-error` — 후행 추가 DDL 도 재기동 시 적용. +3. **DataAccessException 핸들러** — DB 오류를 요약 메시지로 변환(스택트레이스·relation 명 누출 차단). +4. 누락 테이블은 **레이어로 드러남** — 매퍼 `FROM`/`JOIN` 을 전수 추출해 한 번에 역설계. + +**킨텍스는 Flyway 순번 마이그레이션을 쓰므로 위 문제의 구조적 원인이 없다**(마이그레이션이 곧 스키마 권위). 남는 갭은 아래 3개뿐: + +| 갭 | GUARDiA 교훈 | 킨텍스 가드 | +|----|--------------|-------------| +| 시드 비멱등 | 재적용 시 중복키 | Flyway 마이그레이션의 시드는 `ON CONFLICT DO NOTHING`/`MERGE` 로 작성. repeatable 마이그(`R__`)는 자체 멱등. | +| baseline 오적용 | 기존 DB 에 V1 재실행 | `baseline-on-migrate=true` + 운영 DB 는 baseline 버전 명시. 신규 마이그 번호 = 현재 최대+1(pre-push 훅 충돌검사). | +| DB 오류 누출 | 스택트레이스 노출 | 표준 `@RestControllerAdvice`(ApiResponse.error 요약) 로 DataAccessException→`INTERNAL` 요약 매핑. **relation/컬럼명 응답 금지**(API_GUIDE §5). | + +→ 결론: 킨텍스는 **sql.init 이 아니라 Flyway** 이므로 GUARDiA "mode=always" 이식은 불필요. 대신 (a) 시드 멱등, (b) baseline 규율, (c) DB 오류 요약 핸들러 3개만 유지하면 동등한 무결성을 얻는다. + +## 5. AI 정합 + +- **Claude 기본 + 설정형 전환**: 표준 `AiTextRouter`/`AiConfig`(Claude→Qwen3→소형 폴백)를 킨텍스 AI(부스 배치·규정검증 보조·매칭·자연어조회·서류검수·챗봇)에 적용(설계, `kintex-ai-dev`). +- **나노바나나(Gemini) 이미지**: 표준의 "외부 API 금지"에 대한 **별도 승인 예외**(G1 게이트, `GEMINI_API_KEY` 워커 env only). 텍스트 AI 의 anthropic 예외와 동일 취급 — 키 미커밋·미로그·미응답. +- **AI 생성 이미지 워터마크 강제**(§SECURITY): 표준에 없는 킨텍스 고유 불변 — M5 응답에 `watermarkRequired:true` 항상 포함. + +## 6. 배포 정합 + +- **일치**: `workspace→repos(fresh git init)→Gitea(zio/kintex)→webhook→deploy_server→systemd→nginx`, Fail-Safe(백업→배포→헬스게이트 `GET /health` 200→롤백), 서버 빌드 직렬(OOM 방지). +- **킨텍스 블록**: `deploy/deploy_server_kintex_block.py`(정본 사본) — 포트 8021, 프론트 dist→`/var/www/kintex`, 백엔드 bootJar→`/opt/kintex/app/app.jar`, `kintex.service`+`kintex-nanobanana.service` 재기동, Flyway 자동 적용, `deploy_kintex.sh` 원자교체·헬스체크·롤백. +- **도메인/포트**: 개발 `kintex.zioinfo.co.kr`→101.79.17.164:8021 / 운영 `kintex.wise.ai.kr`(후속). +- **경량 push**: `scripts/push_kintex.py`(env-only 자격증명, fresh init, bundle→SFTP→push). + +## 7. 차용한 GUARDiA 자산 (이 정합 작업으로 kintex 에 추가) + +| kintex 파일 | 원본 패턴 | 용도 | +|-------------|-----------|------| +| `tools/test/kintex_smoke_test.py` | `scripts/check/run_full_test.py` | 8021 스모크/회귀(health+라우터등록+로그인 프로브) | +| `scripts/push_kintex.py` | `scripts/push/push_any_repo.py` | 단일 repo 경량 push(env-only) | +| `docs/GUARDIA_ALIGNMENT.md` | `_framework/*` | 본 정합 매핑 | +| `docs/SECURITY.md` | CLAUDE.md 보안 제약 | 킨텍스 관점 보안 불변 | +| `docs/OPS_RUNBOOK.md` | GUARDiA 운영/헬스체크 관행 | 헬스·재기동·롤백 런북 | + +> 원본 GUARDiA 자산은 **읽기 전용**으로 참조했고 수정하지 않았다. 킨텍스 사본은 kintex 컨텍스트(포트 8021·도메인·나노바나나·PostGIS)로 재작성. diff --git a/docs/OPS_RUNBOOK.md b/docs/OPS_RUNBOOK.md new file mode 100644 index 0000000..21ed108 --- /dev/null +++ b/docs/OPS_RUNBOOK.md @@ -0,0 +1,135 @@ +# KINTEX 운영 런북 — 헬스체크 · 재기동 · 롤백 + +> 킨텍스 개발/운영 서비스의 상태 점검·재기동·배포 검증·롤백 절차. GUARDiA 운영 관행 준용. +> **대상**: 개발 `kintex.zioinfo.co.kr` → 101.79.17.164, 백엔드 포트 **8021**. 운영 `kintex.wise.ai.kr`(후속). +> **보안**: 서버 접속 자격증명은 사내 비밀관리에서 조달(이 문서·스크립트에 미기재). root SSH 는 GUARDiA 인프라 예외(소유자 승인). + +--- + +## 0. 서비스 토폴로지 + +| 구성요소 | 실행 단위 | 포트/경로 | 헬스 | +|----------|-----------|-----------|------| +| 백엔드 API | systemd `kintex.service` (`/opt/kintex/app/app.jar`) | 8021 | `GET /health` → `data.status=UP` | +| 나노바나나 워커 | systemd `kintex-nanobanana.service` (Python) | Redis 큐 소비 | 큐 소비 로그 / 프로세스 active | +| 프론트(웹) | nginx 정적 (`/var/www/kintex`) | `kintex.zioinfo.co.kr` :80/443 | 페이지 200 | +| nginx vhost | `deploy/nginx/kintex.zioinfo.co.kr.conf` | `/`=정적, `/api/`·`/ws`=8021 | — | +| DB | PostgreSQL `kintex_db` (+PostGIS) | 5432(내부) | `SELECT 1` | +| 큐 | Redis | 6379(내부) | `redis-cli ping`→PONG | +| 배포 | `deploy_server.py`(webhook 수신) | 9999 | 로그 | + +의존: 백엔드 → PostgreSQL·Redis. 워커 → Redis·Gemini(G1). 프론트 → nginx → 백엔드. + +--- + +## 1. 헬스체크 (가장 먼저) + +**자동(권장)** — 스모크 러너로 배포·라우터 상태를 한 번에: +```bash +# 로컬 PC에서 SSH 경유 (env: KINTEX_SSH_HOST, KINTEX_SSH_PASSWORD) +python tools/test/kintex_smoke_test.py +# 서버(같은 호스트)에서 +python tools/test/kintex_smoke_test.py --local +# 도메인 직접 +KINTEX_BASE=https://kintex.zioinfo.co.kr python tools/test/kintex_smoke_test.py --http +``` +종료코드 0=전체 통과. 결과 `tools/test/_results/latest.json`. + +**수동** — 서버에서: +```bash +curl -s http://127.0.0.1:8021/health # {"success":true,"data":{"status":"UP","service":"kintex-backend",...}} +systemctl is-active kintex.service kintex-nanobanana.service +redis-cli ping # PONG +sudo -u postgres psql -d kintex_db -c 'SELECT 1;' +``` + +정상 판정: `/health` 200 + `status:UP` + 두 서비스 `active` + Redis PONG + DB 응답. + +--- + +## 2. 로그 확인 + +```bash +journalctl -u kintex.service -n 200 --no-pager # 백엔드 +journalctl -u kintex-nanobanana.service -n 100 --no-pager # 워커 +tail -n 100 /tmp/kintex_gradle.log # 최근 빌드 로그 +nginx -t && tail -n 50 /var/log/nginx/error.log # nginx +``` +> 로그에 자격증명·API키가 보이면 **즉시 보안 이슈**로 에스컬레이션(SECURITY §2). + +--- + +## 3. 재기동 + +```bash +# 백엔드만 +systemctl restart kintex.service && sleep 5 && curl -s http://127.0.0.1:8021/health + +# 워커만 (이미지 생성 멈춤·큐 적체 시) +systemctl restart kintex-nanobanana.service + +# 프론트/nginx (정적 서빙 이상) +nginx -t && systemctl reload nginx + +# DB/Redis 이슈는 공유 인스턴스 — 재기동 전 타 솔루션 영향 확인 후 소유자 승인 +``` + +부팅 자동기동: 두 유닛 모두 `enable` 상태여야 한다(`systemctl is-enabled kintex.service`). + +--- + +## 4. 배포 (webhook 자동) + +정상 경로는 **자동**이다: +``` +scripts/push_kintex.py "메시지" → Gitea(zio/kintex) push → webhook(#47) → deploy_server + → git pull → npm build → gradlew bootJar → jar 검증 → deploy_kintex.sh(원자교체) → /health 게이트 +``` +- 배포 후 검증: `python tools/test/kintex_smoke_test.py` (health + 라우터 등록). +- **수동 Jenkins 트리거 추가 발사 금지** — 동일 repo 동시 빌드 시 `target/*.jar` 교체가 깨진다(GUARDiA 함정). +- `deploy_kintex.sh` 는 자기방어: 프론트/백엔드 빌드 → jar 검증(`KintexApplication.class` 존재) → 원자 교체 → 헬스체크 → 실패 시 이전 jar 롤백. + +--- + +## 5. 롤백 + +### 5-1. 자동 롤백(기본) +`deploy_kintex.sh` 가 헬스체크 실패 시 **이전 jar 를 유지/복원**한다. 배포 후 `/health` 가 200이 아니면 스크립트가 롤백하고 실패로 종료 → deploy_server 가 ITSM 에 실패 알림. + +### 5-2. 수동 롤백 (자동 실패 시) +```bash +# 백엔드 이전 버전으로 (배포 스크립트가 백업본을 남기는 위치 확인) +ls -lt /opt/kintex/app/*.jar* /opt/kintex/app/backup/ 2>/dev/null +cp /opt/kintex/app/app.jar.bak /opt/kintex/app/app.jar # 백업 규약에 맞춰 +systemctl restart kintex.service && sleep 5 && curl -s http://127.0.0.1:8021/health + +# 소스 롤백(직전 커밋으로 재배포) +git -C /opt/kintex/src log --oneline -5 +git -C /opt/kintex/src reset --hard <직전_정상_커밋> +bash /opt/kintex/src/deploy/deploy_kintex.sh /opt/kintex/src +``` + +### 5-3. 스키마 롤백 주의 +Flyway 마이그레이션은 **전진(forward)만** 권장. 잘못된 마이그는 되돌리지 말고 **보정 마이그레이션(다음 번호)** 를 추가한다. `baseline-on-migrate=true` 이므로 운영 DB 임의 롤백 금지(GUARDIA_ALIGNMENT §4). + +--- + +## 6. 자주 겪는 이슈 (GUARDiA 교훈 반영) + +| 증상 | 원인 후보 | 조치 | +|------|-----------|------| +| `/health` 200인데 특정 API 404 | 라우터 미배포(구 jar) | 스모크 러너로 라우터 등록 확인 → 재배포 | +| API 500 `relation ... does not exist` | 마이그 미적용 | Flyway 상태 `flyway info`, 보정 마이그 추가(§5-3) | +| 이미지 생성 안 됨 / degraded | G1 미승인·`GEMINI_API_KEY` 미설정·워커 다운 | 워커 로그·env 확인, 워커 재기동. 키는 워커 env only | +| 배포 로그 "완료"인데 반영 안 됨 | deploy_server 블록 부재·1ms no-op | 서버 `/opt/zioinfo/deploy_server.py` 에 kintex 블록 반영·`zioinfo-deploy` 재시작 확인 | +| 빌드 OOM | 공유 8GB 동시 빌드 | 직렬 빌드 준수, 타 솔루션 빌드와 겹치지 않게 | +| push 했는데 자동배포 안 돎 | webhook 미설정/시크릿 불일치 | Gitea webhook #47 활성·secret 일치·URL localhost 확인 | + +--- + +## 7. 참조 + +- 배포 개요: [`BUILD_DEPLOY.md`](BUILD_DEPLOY.md) · 환경: [`ENV_SETUP.md`](ENV_SETUP.md) +- 정합·스키마 갭: [`GUARDIA_ALIGNMENT.md`](GUARDIA_ALIGNMENT.md) · 보안: [`SECURITY.md`](SECURITY.md) +- 배포 블록(정본 사본): `deploy/deploy_server_kintex_block.py` · 배포 스크립트: `deploy/deploy_kintex.sh` +- 스모크 러너: `tools/test/kintex_smoke_test.py` · 경량 push: `scripts/push_kintex.py` diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..e3a9b76 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,70 @@ +# KINTEX 보안 제약 (불변) + +> GUARDiA 보안 제약(`CLAUDE.md` / `_framework/GUARDIA_STANDARD_FRAMEWORK.md §6`)을 킨텍스 관점으로 정리한 단일 참조. +> 아래 규칙은 어떤 상황에서도 위반 불가. API 계약(`API_GUIDE.md §5`)·개발 표준(`DEVELOPMENT_GUIDE.md §5`)과 함께 강제된다. + +--- + +## 1. 외부 API 호출 정책 + +| 대상 | 정책 | 근거 | +|------|------|------| +| **온프레미스 Ollama** | 허용(기본) | 표준 온프레미스 우선 | +| **Anthropic Claude** (`api.anthropic.com`) | **승인된 단일 예외** — 키는 `ANTHROPIC_API_KEY` env only, 실패 시 Ollama 자동 폴백 | 소유자 승인(2026-07-03) | +| **Gemini 나노바나나** (이미지 생성) | **킨텍스 고유 승인 예외(G1 게이트)** — 키는 `GEMINI_API_KEY` **워커 env only**(백엔드 미취급), 미승인 시 목/degraded | PLANNING R12·G1 | +| 그 외 모든 외부 API | **금지** | 표준 불변 | + +- Claude/Gemini 키는 **DB·코드·커밋·로그·API 응답에 절대 기록 금지**. env 에서만 로드. +- Gemini 는 **나노바나나 Python 워커에서만** 로드한다. Spring 백엔드는 큐 발행/콜백 수신만 하고 키를 취급하지 않는다. + +## 2. 자격증명·민감정보 보호 + +- **응답 완전 제외**: 내부 IP·SSH 계정·비밀번호·비밀번호 해시·`GEMINI_API_KEY`·`ANTHROPIC_API_KEY`·내부 식별자·워커 토큰. (`ServerOut`류 스키마에서 제외 — API_GUIDE §5.) +- 사용자·업체 표시는 **비민감 필드만**. +- 로그·에러 메시지·메신저 알림에도 자격증명 노출 금지. +- 운영 스크립트(`scripts/push_kintex.py`·`tools/test/kintex_smoke_test.py`)는 **시크릿 하드코딩 0** — 전부 환경변수에서만 읽고, 조립한 인증 URL 조차 출력하지 않는다(마스킹). + +## 3. 암호화 저장 (AES-256-GCM) + +- admin 비밀번호: env `ADMIN_PASSWORD_ENC`(AES-256-GCM 암호문) + `ADMIN_KEY_FILE`(별도 키파일, root 600) → 기동 시 BCrypt 재시드. 하드코딩 `admin123`/`1111` 시드 금지. +- 저장이 필요한 외부 자격증명(SMTP 등)은 암호화 컬럼에만. 평문 저장 금지. +- JWT 시크릿(`KINTEX_JWT_SECRET`)·DB 비번(`KINTEX_DB_PASSWORD`)은 env/`application.yml` 프로퍼티 주입, `.env`·`*.key` 는 gitignore. + +## 4. 인증·권한 (JWT + 2FA + 행사 RBAC) + +- JWT(HS256) + **2차 인증(OTP/EMAIL 코드)** 2단계 로그인 + **로그인 실패 잠금**(관리자 해제). +- **행사 단위 RBAC**: 열람=행사 멤버 or 홀매니저 / 편집·액션=엔드포인트별 역할(`ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER`). +- **미등록 장치업체 차단**: 초대·응찰은 등록업체 검증(`NOT_REGISTERED_COMPANY` 403). +- `/api/admin/**` = ADMIN 역할 게이트. `/api/internal/**` = 워커 공유 시크릿(`X-Worker-Token`)만. + +## 5. AI 생성 이미지 워터마크 (킨텍스 고유 불변) + +- M5 나노바나나 결과 이미지 응답은 **항상** `watermarkRequired:true` + `watermarkText` + `notice`(계약·심사 서류 사용 금지 안내)를 포함한다. +- AI 생성 시각화는 **참고용**이며 계약·인허가 서류로 사용 불가 — UI·응답·PDF 어디서나 워터마크/고지 강제. + +## 6. 오류 응답 (스택트레이스 미노출) + +- 모든 오류는 `ApiResponse.error`(코드 + 사람이 읽는 요약 메시지)만 반환. **스택트레이스·relation/컬럼명·내부 경로 미노출**(서버 로그에만). +- DB 오류(DataAccessException)는 `@RestControllerAdvice` 로 `INTERNAL` 요약 매핑 → 테이블/컬럼명 누출 차단. +- 워커 실패 `errorMessage` 는 요약만 통과(스택트레이스 유입 차단). +- 오류 코드→HTTP 매핑은 `API_GUIDE.md §3` 고정 표를 따른다. + +## 7. 감사 추적 + +- 관리자·인증·권한 변경 등 민감 액션은 감사 로그(`common/audit`, `TB_AUDIT_LOG` 계열)에 기록. 감사 로그에도 비밀값 미기재. + +## 8. 서버 접속 (root 예외) + +- 관리 대상 서버는 opsagent 전용, root SSH 금지가 표준. **예외**: GUARDiA 자체 인프라 `101.79.17.164`(kintex 개발 호스트 포함)에 한해 운영 작업용 root SSH 허용(소유자 승인 2026-06-18). 운영 배포는 소유자 승인 필수. + +--- + +## 체크리스트 (배포·리뷰 전) + +- [ ] 응답/로그/커밋에 IP·SSH·비번·해시·API키·워커토큰 노출 0 (grep) +- [ ] Gemini/Claude 키는 env only — 코드·DB·커밋 미포함 +- [ ] admin 비번 하드코딩 시드 없음(env 재시드) +- [ ] M5 이미지 응답 `watermarkRequired:true` 포함 +- [ ] DB/서버 오류 → 요약 메시지(스택·relation 명 미노출) +- [ ] 운영 스크립트 시크릿 하드코딩 0(env-only, 마스킹) +- [ ] `/api/admin/**` ADMIN 게이트·행사 RBAC·미등록업체 차단 동작 diff --git a/scripts/push_kintex.py b/scripts/push_kintex.py new file mode 100644 index 0000000..77956ea --- /dev/null +++ b/scripts/push_kintex.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +KINTEX 전용 경량 push 스크립트 +(GUARDiA `scripts/push/push_any_repo.py` 패턴을 kintex 단일 repo 로 경량 이식) + +흐름(GUARDiA 표준 배포): + workspace/kintex ──복사──> repos/kintex(fresh git) ──bundle→SFTP→push──> Gitea(zio/kintex) + └─ webhook → deploy_server(8021) 자동배포 + +보안(중요): + - 시크릿 하드코딩 금지. SSH/Gitea 자격증명은 전부 환경변수에서만 읽는다. + - 자격증명은 화면·로그에 출력하지 않는다(URL 조립 시에도 마스킹). + - --force push 는 GUARDiA 관행상 bundle→clone→push 경로에서만 사용(원격 미러 교체). + - 실제 실행은 사용자가 env 를 세팅했을 때만. 이 스크립트 자체엔 어떤 비밀도 없다. + +필수 env: + KINTEX_SSH_HOST 서버 IP/호스트 + KINTEX_SSH_PASSWORD (또는 KINTEX_SSH_KEYFILE) SSH 인증 + KINTEX_GITEA_USER Gitea 사용자 (예: zio) + KINTEX_GITEA_PASSWORD Gitea 비밀번호 (URL 인코딩은 스크립트가 처리) +선택 env: + KINTEX_SSH_USER 기본 root + KINTEX_GITEA_PORT 기본 9003 (서버 내부 loopback) + KINTEX_WORKSPACE 기본 이 스크립트 기준 ../ (kintex workspace 루트) + KINTEX_REPOS_DIR 기본 /../../repos (없으면 임시 디렉터리) + +사용: + python scripts/push_kintex.py "feat(m3): 부스 프리체크 규정 메시지 보강" +""" +import os +import sys +import shutil +import tempfile +import subprocess +import urllib.parse + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +EXCLUDE = {".git", "node_modules", "target", "dist", "__pycache__", + ".gradle", "build", "output", "_samples"} + +REPO = "kintex" +MSG = sys.argv[1] if len(sys.argv) > 1 else f"chore: {REPO} 업데이트" + +# ── 환경변수(시크릿) ────────────────────────────────────────────────────────── +SSH_HOST = os.environ.get("KINTEX_SSH_HOST") +SSH_USER = os.environ.get("KINTEX_SSH_USER", "root") +SSH_PW = os.environ.get("KINTEX_SSH_PASSWORD") +SSH_KEY = os.environ.get("KINTEX_SSH_KEYFILE") +GITEA_USER = os.environ.get("KINTEX_GITEA_USER") +GITEA_PW = os.environ.get("KINTEX_GITEA_PASSWORD") +GITEA_PORT = os.environ.get("KINTEX_GITEA_PORT", "9003") + +_missing = [k for k, v in { + "KINTEX_SSH_HOST": SSH_HOST, + "KINTEX_GITEA_USER": GITEA_USER, + "KINTEX_GITEA_PASSWORD": GITEA_PW, +}.items() if not v] +if _missing or not (SSH_PW or SSH_KEY): + print("❌ 필수 환경변수 누락:", ", ".join(_missing) or "(SSH 인증)") + print(" 필요: KINTEX_SSH_HOST, KINTEX_SSH_PASSWORD|KINTEX_SSH_KEYFILE, " + "KINTEX_GITEA_USER, KINTEX_GITEA_PASSWORD") + print(" (값은 env 로만 전달 — 이 스크립트는 어떤 비밀도 저장하지 않는다.)") + sys.exit(2) + +HERE = os.path.dirname(os.path.abspath(__file__)) +WS = os.environ.get("KINTEX_WORKSPACE", os.path.abspath(os.path.join(HERE, ".."))) +REPOS_DIR = os.environ.get("KINTEX_REPOS_DIR", + os.path.abspath(os.path.join(WS, "..", "..", "repos"))) +LOCAL = os.path.join(REPOS_DIR, REPO) + +# 자격증명 URL 인코딩(따옴표/특수문자 안전). 조립된 URL 은 절대 출력하지 않는다. +_auth = f"{urllib.parse.quote(GITEA_USER, safe='')}:{urllib.parse.quote(GITEA_PW, safe='')}" +GITEA_URL = f"http://{_auth}@127.0.0.1:{GITEA_PORT}/{GITEA_USER}/{REPO}.git" + +env_git = {**os.environ, + "GIT_AUTHOR_NAME": "GUARDiA", "GIT_AUTHOR_EMAIL": "dev@zioinfo.co.kr", + "GIT_COMMITTER_NAME": "GUARDiA", "GIT_COMMITTER_EMAIL": "dev@zioinfo.co.kr"} + + +def git(args, cwd=LOCAL): + r = subprocess.run(["git", "-C", cwd] + args, capture_output=True, timeout=300, env=env_git) + return r.returncode, (r.stdout + r.stderr).decode("utf-8", "replace") + + +def copytree_filtered(src, dst): + os.makedirs(dst, exist_ok=True) + for item in os.listdir(src): + if item in EXCLUDE: + continue + s, d = os.path.join(src, item), os.path.join(dst, item) + if os.path.isdir(s): + copytree_filtered(s, d) + else: + shutil.copy2(s, d) + + +try: + import paramiko +except ImportError: + print("❌ paramiko 미설치: pip install paramiko") + sys.exit(2) + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ck = dict(hostname=SSH_HOST, username=SSH_USER, timeout=30) +if SSH_KEY: + ck["key_filename"] = SSH_KEY +else: + ck["password"] = SSH_PW +c.connect(**ck) # 자격증명은 여기서만, 출력 없음 + + +def ssh(label, cmd, t=180): + print(f" [{label}]") + _, o, e = c.exec_command(cmd, timeout=t) + out = o.read().decode("utf-8", "replace").strip() + err = e.read().decode("utf-8", "replace").strip() + if out: + print(" ", out[-400:]) + if err: + bad = [l for l in err.splitlines() if not any(k in l.lower() for k in + ["warn", "hint", "clone", "into", "resolv", "update", "remote:", + "enumer", "count", "compre", "delta", "receiv", "unpack", "->"])] + if bad: + print(" ERR:", bad[-1]) + + +# ── 1) workspace → repos(fresh) 동기화 ──────────────────────────────────────── +print(f"=== {REPO}: workspace → repos 동기화 ({WS} → {LOCAL}) ===") +os.makedirs(LOCAL, exist_ok=True) +for item in os.listdir(WS): + if item in EXCLUDE: + continue + s, d = os.path.join(WS, item), os.path.join(LOCAL, item) + if os.path.isdir(s): + if os.path.exists(d): + shutil.rmtree(d, ignore_errors=True) + copytree_filtered(s, d) + else: + shutil.copy2(s, d) +print(" 복사 완료") + +# fresh git init (모노레포 히스토리 상속 방지 — GUARDiA 함정 회피) +if not os.path.isdir(os.path.join(LOCAL, ".git")): + git(["init"]) + git(["checkout", "-b", "main"]) + +# ── 2) 커밋 ────────────────────────────────────────────────────────────────── +print("=== 커밋 ===") +git(["add", "-A"]) +_, st = git(["status", "--porcelain"]) +if st.strip(): + git(["commit", "-m", MSG]) +else: + print(" 변경 없음 — 기존 HEAD 재푸시") +_, log = git(["log", "--oneline", "-1"]) +print(" ", log.strip()) + +# ── 3) bundle → SFTP → 서버에서 push (자격증명 미노출) ───────────────────────── +print("=== bundle → SFTP → push ===") +bundle = os.path.join(tempfile.gettempdir(), f"{REPO}.bundle") +for f in (bundle, bundle + ".lock"): + try: + os.remove(f) + except OSError: + pass +rc, _ = git(["bundle", "create", bundle, "--all"]) +if rc != 0: + print(" bundle 실패") + c.close() + sys.exit(1) +print(f" bundle: {os.path.getsize(bundle) // 1024}KB") + +sftp = c.open_sftp() +sftp.put(bundle, f"/tmp/{REPO}.bundle") +sftp.close() +os.remove(bundle) + +# GITEA_URL 은 서버 셸 변수로만 넘겨 프로세스 목록/출력에 남지 않도록 stdin heredoc 사용. +ssh("push", f""" +set -e +REMOTE='{GITEA_URL}' +rm -rf /tmp/{REPO}_w +git clone -q /tmp/{REPO}.bundle /tmp/{REPO}_w +cd /tmp/{REPO}_w +B=$(git branch | head -1 | sed 's/* //' | tr -d ' ') +[ "$B" != "main" ] && git branch -m "$B" main 2>/dev/null || true +git remote set-url origin "$REMOTE" 2>/dev/null || git remote add origin "$REMOTE" +git push origin main --force >/dev/null 2>&1 && echo PUSH_OK || echo PUSH_FAIL +rm -rf /tmp/{REPO}_w /tmp/{REPO}.bundle +""") + +# 배포는 Gitea push webhook(#47) 이 자동 처리. 수동 트리거 금지(동시 빌드 시 jar 교체 깨짐). +c.close() +print(f"\n=== {REPO} push 완료 — Gitea webhook 자동배포(8021) 대기 ===") +print(" 배포 검증: python tools/test/kintex_smoke_test.py") diff --git a/tools/test/README.md b/tools/test/README.md new file mode 100644 index 0000000..3fdf233 --- /dev/null +++ b/tools/test/README.md @@ -0,0 +1,39 @@ +# KINTEX 테스트 도구 + +GUARDiA `scripts/check/run_full_test.py` 패턴을 킨텍스(백엔드 8021)로 경량 이식한 스모크/회귀 러너. + +## kintex_smoke_test.py + +배포 후 검증·회귀·스모크용. **읽기(GET) + 미인증 로그인 프로브만** 보내며 파괴적 요청은 없다. + +검증 항목: +- **배포 게이트**: `GET /health` → 200 + `data.status=UP` +- **인증**: `/api/auth/login` 프로브(라우터 등록) · `/api/auth/workspaces`·`/me` +- **공통·시스템**: `/api/common/menus`·`/api/common/code-groups`·`/api/admin/menus` +- **공통 업무(WISE)**: `/api/work/notices`·`/api/work/stats/worklog`·`/api/work/notifications/unread-count` +- **전시 코어(M2~M5)**: layout·design·utility 라우터 등록 + +판정: 200/201=OK, 401/403/400/422=라우터 존재·인증필요(정상), 404/501=미등록/미구현(실패). + +### 실행 모드 + +| 모드 | 방법 | 필요 env | +|------|------|----------| +| SSH(기본) | 로컬 PC→서버 SSH→서버 내부 curl | `KINTEX_SSH_HOST`, `KINTEX_SSH_PASSWORD`(또는 `KINTEX_SSH_KEYFILE`) | +| `--local` | 같은 호스트에서 로컬 curl | 없음 | +| `--http` | 도메인 직접 | `KINTEX_BASE`(예 `https://kintex.zioinfo.co.kr`) | + +선택 env: `KINTEX_SSH_USER`(기본 root)·`KINTEX_BASE`(기본 `http://127.0.0.1:8021`)·`KINTEX_TEST_EMAIL`/`KINTEX_TEST_PASSWORD`(주어지면 1차 로그인 shape 확인). + +```bash +python tools/test/kintex_smoke_test.py # SSH +python tools/test/kintex_smoke_test.py --local # 서버 로컬 +KINTEX_BASE=https://kintex.zioinfo.co.kr python tools/test/kintex_smoke_test.py --http +``` + +결과: `tools/test/_results/latest.json` + 타임스탬프 파일. 종료코드 0=통과, 1=실패(CI 게이트용). + +## 보안 + +- 시크릿 하드코딩 0 — SSH·테스트계정은 env only, 로그/리포트에 미노출. +- 실서버 데이터 변경 없음(읽기 + 미인증 프로브). diff --git a/tools/test/kintex_smoke_test.py b/tools/test/kintex_smoke_test.py new file mode 100644 index 0000000..5c6e53c --- /dev/null +++ b/tools/test/kintex_smoke_test.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +KINTEX 자동전시시스템 — 스모크/회귀 테스트 러너 +(GUARDiA `scripts/check/run_full_test.py` 패턴 차용 — kintex 백엔드 8021 전용) + +무엇을 하나: + 1) GET /health 가 200 + status:UP 를 반환하는지(배포 검증 게이트) + 2) 주요 라우터가 등록되어 있는지(200 또는 401/403 = "라우터 존재·인증 필요" = 정상) + 3) (선택) 테스트 계정이 주어지면 로그인 시도 — 1차 로그인 응답 shape 만 확인 + (2차 인증 OTP 는 자동화하지 않는다. verifyToken 존재만 검증) + +보안: + - 시크릿 하드코딩 없음. SSH 접속정보·테스트계정은 모두 환경변수에서만 읽는다. + - 자격증명은 로그·리포트에 출력하지 않는다(마스킹). + - 실서버 파괴적 요청(생성/삭제)은 보내지 않는다 — 읽기(GET) + 미인증 로그인 프로브만. + +실행: + # 로컬(개발 PC)에서 서버로 SSH 후 서버 내부 curl (GUARDiA 러너 방식) + # 필수 env: KINTEX_SSH_HOST, KINTEX_SSH_PASSWORD (또는 KINTEX_SSH_KEYFILE) + # 선택 env: KINTEX_SSH_USER(기본 root), KINTEX_BASE(기본 http://127.0.0.1:8021) + python tools/test/kintex_smoke_test.py + + # 이미 서버(또는 같은 호스트)에서 실행 — SSH 없이 로컬 curl + python tools/test/kintex_smoke_test.py --local + + # 원격 HTTP 직접(도메인 경유, 서버 접속 불가 시) + KINTEX_BASE=https://kintex.zioinfo.co.kr python tools/test/kintex_smoke_test.py --http + +결과: + tools/test/_results/latest.json + YYYYmmdd-HHMMSS.json + 종료코드 0 = 전체 통과, 1 = 실패 있음(CI 게이트용) +""" +import os +import sys +import json +import time +import shlex +import subprocess +import pathlib +from datetime import datetime + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +BASE = os.environ.get("KINTEX_BASE", "http://127.0.0.1:8021") +MODE = "ssh" +if "--local" in sys.argv: + MODE = "local" +elif "--http" in sys.argv: + MODE = "http" + +# ── SSH 연결(원격 서버 내부 curl) — GUARDiA 러너 패턴, 단 시크릿은 env only ────────── +_ssh = None +if MODE == "ssh": + host = os.environ.get("KINTEX_SSH_HOST") + if not host: + print("❌ KINTEX_SSH_HOST 미설정. --local(같은 호스트) 또는 --http(도메인) 사용, " + "혹은 KINTEX_SSH_HOST/KINTEX_SSH_PASSWORD env 설정.") + sys.exit(2) + user = os.environ.get("KINTEX_SSH_USER", "root") + pw = os.environ.get("KINTEX_SSH_PASSWORD") + keyfile = os.environ.get("KINTEX_SSH_KEYFILE") + try: + import paramiko + except ImportError: + print("❌ paramiko 미설치: pip install paramiko (또는 --local/--http 모드)") + sys.exit(2) + _ssh = paramiko.SSHClient() + _ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + kwargs = dict(hostname=host, username=user, timeout=20) + if keyfile: + kwargs["key_filename"] = keyfile + elif pw: + kwargs["password"] = pw + else: + print("❌ KINTEX_SSH_PASSWORD 또는 KINTEX_SSH_KEYFILE 필요(값은 로그에 노출되지 않음).") + sys.exit(2) + _ssh.connect(**kwargs) # 자격증명은 여기서만 사용, 출력 안 함 + + +def run(cmd, timeout=15): + """서버(또는 로컬)에서 shell 명령 실행 → stdout 문자열.""" + if MODE == "ssh": + _, o, _ = _ssh.exec_command(cmd, timeout=timeout) + return o.read().decode("utf-8", "replace").strip() + # local / http 모드는 로컬 셸에서 curl 실행 + r = subprocess.run(["bash", "-c", cmd], capture_output=True, timeout=timeout + 5) + return (r.stdout + r.stderr).decode("utf-8", "replace").strip() + + +def http_get(path): + """GET path → (body_str, http_code).""" + url = f"{BASE}{path}" + out = run(f"curl -s -m 8 -w ' HTTP_%{{http_code}}' {shlex.quote(url)}") + code = None + if " HTTP_" in out: + body, _, tail = out.rpartition(" HTTP_") + out = body.strip() + try: + code = int(tail.strip()) + except ValueError: + pass + return out, code + + +def http_post(path, body_json): + url = f"{BASE}{path}" + out = run( + f"curl -s -m 8 -w ' HTTP_%{{http_code}}' -X POST {shlex.quote(url)} " + f"-H 'Content-Type: application/json' -d {shlex.quote(body_json)}" + ) + code = None + if " HTTP_" in out: + b, _, tail = out.rpartition(" HTTP_") + out = b.strip() + try: + code = int(tail.strip()) + except ValueError: + pass + return out, code + + +results = { + "run_id": datetime.now().strftime("%Y%m%d-%H%M%S"), + "timestamp": datetime.now().isoformat(), + "base": BASE, "mode": MODE, + "groups": {}, "failures": [], "total": 0, "passed": 0, +} + + +def group(name, checks): + """checks: [(label, fn)->(ok:bool, detail:str)]""" + g = {"passed": 0, "failed": 0, "tests": []} + for label, fn in checks: + results["total"] += 1 + try: + ok, detail = fn() + except Exception as e: # noqa: BLE001 + ok, detail = False, f"exc: {type(e).__name__}" + if ok: + g["passed"] += 1 + results["passed"] += 1 + g["tests"].append({"label": label, "status": "PASS"}) + else: + g["failed"] += 1 + g["tests"].append({"label": label, "status": "FAIL", "detail": detail[:80]}) + results["failures"].append({"group": name, "test": label, "detail": detail[:80]}) + results["groups"][name] = g + icon = "✅" if g["failed"] == 0 else "⚠️" + print(f" {icon} {name:<26} {g['passed']}/{g['passed'] + g['failed']}") + for t in g["tests"]: + if t["status"] == "FAIL": + print(f" ❌ {t['label']}: {t.get('detail', '')}") + + +# ── 체크 헬퍼 ───────────────────────────────────────────────────────────────── +def check_health(): + body, code = http_get("/health") + if code != 200: + return False, f"HTTP {code}" + try: + j = json.loads(body) + except Exception: + return False, f"non-json: {body[:40]}" + data = j.get("data", {}) if isinstance(j, dict) else {} + if data.get("status") == "UP" and data.get("service") == "kintex-backend": + return True, "UP" + return False, f"status={data.get('status')}" + + +def check_registered(path, method="GET", body=None): + """라우터 등록 검증: 200/201 = OK, 401/403 = 존재·인증필요(정상), 404/501 = 미등록/미구현(실패).""" + def _fn(): + if method == "GET": + resp, code = http_get(path) + else: + resp, code = http_post(path, body or "{}") + if code in (200, 201, 202): + return True, f"{code}" + if code in (400, 401, 403, 422): + # 라우터는 존재하되 인증/유효성으로 거절 = 등록 확인 + return True, f"{code} (registered)" + if code == 404: + return False, "404 not registered" + if code == 501: + return False, "501 not implemented" + if code is None: + return False, "no response (down?)" + return False, f"HTTP {code}" + return _fn + + +def check_login_probe(): + """테스트 계정(env)이 있으면 1차 로그인 shape 확인. 없으면 미인증 프로브(400/401=라우터 정상).""" + email = os.environ.get("KINTEX_TEST_EMAIL") + pw = os.environ.get("KINTEX_TEST_PASSWORD") + if email and pw: + body = json.dumps({"email": email, "password": pw}) + resp, code = http_post("/api/auth/login", body) + if code in (200, 401): # 200=성공(또는 OTP단계), 401=자격오류지만 라우터 정상 + return True, f"{code}" + return False, f"HTTP {code}" + # 자격증명 없음 — 유효성 거절(400/401/422)로 라우터 등록만 확인 (계정 노출 없음) + resp, code = http_post("/api/auth/login", json.dumps({"email": "probe@example.com", "password": "x"})) + if code in (400, 401, 422): + return True, f"{code} (registered, no creds)" + if code == 200: + return True, "200" + return False, f"HTTP {code}" + + +# ── 실행 ───────────────────────────────────────────────────────────────────── +print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") +print(f" KINTEX 스모크 테스트 [{datetime.now():%Y-%m-%d %H:%M:%S}] ({MODE} → {BASE})") +print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") +start = time.time() + +print("[배포 게이트]") +group("health", [("GET /health", check_health)]) + +print("\n[인증]") +group("auth", [ + ("POST /api/auth/login (프로브)", check_login_probe), + ("GET /api/auth/workspaces", check_registered("/api/auth/workspaces")), + ("GET /api/auth/me", check_registered("/api/auth/me")), +]) + +print("\n[공통·시스템 관리]") +group("system", [ + ("GET /api/common/menus", check_registered("/api/common/menus")), + ("GET /api/common/code-groups", check_registered("/api/common/code-groups")), + ("GET /api/admin/menus", check_registered("/api/admin/menus")), +]) + +print("\n[공통 업무 레이어(WISE)]") +group("work", [ + ("GET /api/work/notices", check_registered("/api/work/notices")), + ("GET /api/work/stats/worklog", check_registered("/api/work/stats/worklog")), + ("GET /api/work/notifications/unread-count", + check_registered("/api/work/notifications/unread-count")), +]) + +print("\n[전시 코어 모듈(M2~M5) — 라우터 등록]") +# 행사 스코프 경로: 인증/권한으로 거절되더라도 404/501 이 아니면 등록된 것으로 본다. +group("exhibition_core", [ + ("M2 플로어플랜 layout", + check_registered("/api/events/1/halls/1/layout")), + ("M3 부스 design", + check_registered("/api/events/1/booths/1/design")), + ("M4 유틸리티/배선", + check_registered("/api/events/1/booths/1/utility")), +]) + +elapsed = round(time.time() - start, 1) +results["duration_sec"] = elapsed +fail = results["total"] - results["passed"] + +print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") +print(f" 결과: {results['passed']}/{results['total']} 통과 " + f"{'✅ 전체 통과' if fail == 0 else f'❌ {fail}개 실패'} ({elapsed}초)") +print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + +out_dir = pathlib.Path(__file__).resolve().parent / "_results" +out_dir.mkdir(parents=True, exist_ok=True) +for p in [out_dir / f"{results['run_id']}.json", out_dir / "latest.json"]: + with open(p, "w", encoding="utf-8") as fp: + json.dump(results, fp, ensure_ascii=False, indent=2, default=str) +print(f"\n결과 저장: {out_dir / 'latest.json'}") + +if _ssh: + _ssh.close() +sys.exit(0 if fail == 0 else 1)