#!/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)