kintex/tools/test/kintex_smoke_test.py

272 lines
11 KiB
Python

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