G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
273 lines
12 KiB
Python
273 lines
12 KiB
Python
"""E-1 월별 리포트 자동 생성 테스트"""
|
|
import sys, ast, os, json, re, hashlib
|
|
from datetime import datetime
|
|
from calendar import monthrange
|
|
|
|
os.environ.setdefault("GUARDIA_SECRET_KEY", "test-e1-secret-key-32bytes-padded!")
|
|
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_e1.db")
|
|
|
|
ok = True
|
|
|
|
print("=== 1. 구문 검사 ===")
|
|
files = ["routers/report.py", "main.py"]
|
|
for f in files:
|
|
try:
|
|
with open(f, encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
ast.parse(src)
|
|
print(f" OK {f}")
|
|
except SyntaxError as e:
|
|
print(f" ERR {f}: {e}")
|
|
ok = False
|
|
|
|
print("\n=== 2. routers/report.py 엔드포인트 확인 ===")
|
|
with open("routers/report.py", encoding="utf-8") as f:
|
|
report_src = f.read()
|
|
|
|
checks = [
|
|
('@router.get("/generate"', "GET /generate 리포트 즉시 생성"),
|
|
('@router.get("/monthly/{year}/{month}"', "GET /monthly/{year}/{month}"),
|
|
('@router.get("/list"', "GET /list 목록"),
|
|
('@router.get("/preview"', "GET /preview 미리보기"),
|
|
('@router.get("/{report_id}"', "GET /{report_id} 상세"),
|
|
('@router.get("/export/{report_id}"', "GET /export/{report_id} 다운로드"),
|
|
('@router.post("/schedule"', "POST /schedule 스케줄 설정"),
|
|
("generate_monthly_report", "generate_monthly_report 함수"),
|
|
("_gather_sr_stats", "_gather_sr_stats 통계 함수"),
|
|
("_gather_audit_stats", "_gather_audit_stats 감사 함수"),
|
|
("_gather_capacity_stats", "_gather_capacity_stats 용량 함수"),
|
|
("_llm_generate_summary", "_llm_generate_summary LLM 함수"),
|
|
("_build_fallback_summary", "_build_fallback_summary 규칙 요약"),
|
|
("_build_recommendations", "_build_recommendations 권고사항"),
|
|
("localhost:11434", "Ollama 내부 LLM (외부 API 금지 준수)"),
|
|
("StreamingResponse", "StreamingResponse (JSON 다운로드)"),
|
|
("Content-Disposition", "Content-Disposition 헤더"),
|
|
("UserRole.ADMIN", "ADMIN 권한 제한"),
|
|
("UserRole.PM", "PM 권한 제한"),
|
|
("ScheduleConfigIn", "ScheduleConfigIn 스키마"),
|
|
("health_score", "health_score 헬스 스코어"),
|
|
("health_grade", "health_grade 등급"),
|
|
("_reports", "인메모리 리포트 캐시"),
|
|
("RPT-", "RPT- 리포트 ID 포맷"),
|
|
("resolution_rate", "resolution_rate SR 해결률"),
|
|
("executive_summary", "executive_summary 경영진 요약"),
|
|
("recommendations", "recommendations 권고사항"),
|
|
]
|
|
|
|
for sym, desc in checks:
|
|
status = "OK" if sym in report_src else "ERR"
|
|
if status == "ERR": ok = False
|
|
print(f" {status} {desc}")
|
|
|
|
print("\n=== 3. main.py E-1 라우터 등록 확인 ===")
|
|
with open("main.py", encoding="utf-8") as f:
|
|
main_src = f.read()
|
|
|
|
main_checks = [
|
|
("report," in main_src or "report\n" in main_src, "report 임포트"),
|
|
("report.router" in main_src, "report.router 등록"),
|
|
("E-1" in main_src, "E-1 주석"),
|
|
]
|
|
for check, desc in main_checks:
|
|
status = "OK" if check else "ERR"
|
|
if status == "ERR": ok = False
|
|
print(f" {status} {desc}")
|
|
|
|
print("\n=== 4. 리포트 ID 포맷 검증 ===")
|
|
try:
|
|
year, month = 2026, 5
|
|
requester = "admin"
|
|
report_id = f"RPT-{year}{month:02d}-{hashlib.sha256(f'{year}{month}{requester}'.encode()).hexdigest()[:6].upper()}"
|
|
assert report_id.startswith("RPT-"), f"RPT- 접두사 없음: {report_id}"
|
|
assert len(report_id) == 17, f"ID 길이 오류: {len(report_id)} (기대: 17)"
|
|
assert re.match(r"RPT-\d{6}-[A-F0-9]{6}", report_id), f"포맷 불일치: {report_id}"
|
|
print(f" OK 리포트 ID 생성: {report_id}")
|
|
print(f" OK ID 길이: {len(report_id)}자 (기대: 16)")
|
|
print(f" OK 포맷 정규식 통과: RPT-YYYYMM-XXXXXX")
|
|
except AssertionError as e:
|
|
print(f" ERR {e}")
|
|
ok = False
|
|
|
|
print("\n=== 5. 헬스 스코어 알고리즘 검증 ===")
|
|
try:
|
|
def calc_health(critical_events, critical_plans, resolution_rate):
|
|
health_deductions = (
|
|
min(30, critical_events * 5) +
|
|
min(20, critical_plans * 10) +
|
|
max(0, 20 - resolution_rate // 5)
|
|
)
|
|
return max(0, 100 - int(health_deductions))
|
|
|
|
# 완벽한 운영
|
|
score_perfect = calc_health(0, 0, 100)
|
|
assert score_perfect == 100, f"완벽 점수 오류: {score_perfect}"
|
|
print(f" OK 완벽 운영: 헬스 스코어 = {score_perfect} (기대: 100)")
|
|
|
|
# CRITICAL 이벤트 6건 → -30
|
|
score_critical = calc_health(6, 0, 100)
|
|
assert score_critical == 70, f"CRITICAL 6건 점수 오류: {score_critical}"
|
|
print(f" OK CRITICAL 6건: 헬스 스코어 = {score_critical} (기대: 70)")
|
|
|
|
# 용량 위험 2개 → -20
|
|
score_capacity = calc_health(0, 2, 100)
|
|
assert score_capacity == 80, f"용량 위험 2개 점수 오류: {score_capacity}"
|
|
print(f" OK 용량 위험 2개: 헬스 스코어 = {score_capacity} (기대: 80)")
|
|
|
|
# SR 해결률 0% → -20
|
|
score_sr = calc_health(0, 0, 0)
|
|
assert score_sr == 80, f"SR 해결률 0% 점수 오류: {score_sr}"
|
|
print(f" OK SR 해결률 0%: 헬스 스코어 = {score_sr} (기대: 80)")
|
|
|
|
# 최악 시나리오 (최대 감점 30+20+20=70 → 최소 30)
|
|
score_worst = calc_health(100, 100, 0)
|
|
assert score_worst == 30, f"최악 시나리오 점수 오류: {score_worst} (기대: 30)"
|
|
print(f" OK 최악 시나리오: 헬스 스코어 = {score_worst} (기대: 30, 최대 감점=70)")
|
|
|
|
# 등급 구분
|
|
grade_tests = [
|
|
(95, "A"), (90, "A"), (80, "B"), (75, "B"), (65, "C"), (60, "C"), (50, "D"),
|
|
]
|
|
for score, expected in grade_tests:
|
|
grade = "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 60 else "D"
|
|
assert grade == expected, f"등급 오류: score={score} grade={grade} (기대: {expected})"
|
|
print(f" OK 등급 구분 (A/B/C/D) 모두 정확")
|
|
|
|
except AssertionError as e:
|
|
print(f" ERR {e}")
|
|
ok = False
|
|
|
|
print("\n=== 6. 권고사항 생성 로직 검증 ===")
|
|
try:
|
|
def build_recommendations(stats):
|
|
recs = []
|
|
sr = stats.get("sr", {})
|
|
sec = stats.get("security", {})
|
|
cap = stats.get("capacity", {})
|
|
if sr.get("resolution_rate", 100) < 80:
|
|
recs.append(f"SR 해결률 미달")
|
|
if sr.get("open", 0) > 10:
|
|
recs.append(f"미처리 SR 다수")
|
|
if sec.get("critical_events", 0) > 0:
|
|
recs.append(f"CRITICAL 보안 이벤트 조사 필요")
|
|
if cap.get("critical_plans", 0) > 0:
|
|
recs.append(f"용량 위험 시스템 증설 권고")
|
|
if not recs:
|
|
recs.append("운영 지표 정상")
|
|
return recs
|
|
|
|
# 정상 운영 → 정상 메시지
|
|
recs_ok = build_recommendations({"sr": {"resolution_rate": 95, "open": 3},
|
|
"security": {"critical_events": 0},
|
|
"capacity": {"critical_plans": 0}})
|
|
assert len(recs_ok) == 1 and "정상" in recs_ok[0], f"정상 권고 오류: {recs_ok}"
|
|
print(f" OK 정상 운영: '{recs_ok[0]}'")
|
|
|
|
# 문제 다수 → 다건 권고
|
|
recs_multi = build_recommendations({"sr": {"resolution_rate": 60, "open": 15},
|
|
"security": {"critical_events": 2},
|
|
"capacity": {"critical_plans": 1}})
|
|
assert len(recs_multi) == 4, f"다건 권고 개수 오류: {len(recs_multi)} (기대: 4)"
|
|
print(f" OK 문제 다수: 권고 {len(recs_multi)}건 생성")
|
|
|
|
except AssertionError as e:
|
|
print(f" ERR {e}")
|
|
ok = False
|
|
|
|
print("\n=== 7. Fallback 요약 생성 검증 ===")
|
|
try:
|
|
def build_fallback(stats, year, month):
|
|
sr = stats.get("sr", {})
|
|
sec = stats.get("security", {})
|
|
cap = stats.get("capacity", {})
|
|
lines = [
|
|
f"{year}년 {month}월 GUARDiA ITSM 운영 월간 보고서입니다.",
|
|
f"이번 달 총 SR {sr.get('total', 0)}건이 접수되어 해결률 {sr.get('resolution_rate', 0)}%를 달성했습니다.",
|
|
f"보안 감사 이벤트는 총 {sec.get('total_events', 0)}건이며, "
|
|
f"중요(CRITICAL) 이벤트는 {sec.get('critical_events', 0)}건입니다.",
|
|
]
|
|
if cap.get("critical_plans", 0) > 0:
|
|
lines.append(f"용량 위험 시스템이 {cap['critical_plans']}개 감지되어 즉각적인 조치가 필요합니다.")
|
|
return " ".join(lines)
|
|
|
|
summary = build_fallback({
|
|
"sr": {"total": 120, "resolution_rate": 92.5},
|
|
"security": {"total_events": 450, "critical_events": 0},
|
|
"capacity": {"critical_plans": 0},
|
|
}, 2026, 5)
|
|
assert "2026년 5월" in summary, "연월 포함 오류"
|
|
assert "120건" in summary, "SR 건수 포함 오류"
|
|
assert "92.5%" in summary, "해결률 포함 오류"
|
|
print(f" OK Fallback 요약 생성 성공")
|
|
print(f" OK 내용: {summary[:80]}...")
|
|
|
|
# 용량 위험 포함
|
|
summary2 = build_fallback({
|
|
"sr": {"total": 50, "resolution_rate": 70.0},
|
|
"security": {"total_events": 100, "critical_events": 3},
|
|
"capacity": {"critical_plans": 2},
|
|
}, 2026, 5)
|
|
assert "용량 위험" in summary2, "용량 위험 문구 포함 오류"
|
|
print(f" OK 용량 위험 시스템 언급 확인")
|
|
|
|
except AssertionError as e:
|
|
print(f" ERR {e}")
|
|
ok = False
|
|
|
|
print("\n=== 8. ScheduleConfigIn 스키마 확인 ===")
|
|
schedule_checks = [
|
|
("send_day", "send_day 필드"),
|
|
("recipients", "recipients 필드"),
|
|
("include_llm", "include_llm 필드"),
|
|
("enabled", "enabled 필드"),
|
|
("1 <= body.send_day <= 28", "send_day 범위 검증 (1~28)"),
|
|
]
|
|
for sym, desc in schedule_checks:
|
|
status = "OK" if sym in report_src else "ERR"
|
|
if status == "ERR": ok = False
|
|
print(f" {status} {desc}")
|
|
|
|
print("\n=== 9. 보안 정책 확인 ===")
|
|
security_checks = [
|
|
("localhost:11434" in report_src and "openai" not in report_src.lower()
|
|
and "anthropic" not in report_src.lower(),
|
|
"외부 AI API 미사용 (Ollama only)"),
|
|
("UserRole.ADMIN" in report_src, "ADMIN 전용 기능 존재"),
|
|
("attachment; filename=" in report_src, "다운로드 파일명 설정"),
|
|
("ensure_ascii=False" in report_src, "한글 JSON 인코딩"),
|
|
]
|
|
for check, desc in security_checks:
|
|
status = "OK" if check else "ERR"
|
|
if status == "ERR": ok = False
|
|
print(f" {status} {desc}")
|
|
|
|
print("\n=== 10. 캘린더 유틸리티 검증 ===")
|
|
try:
|
|
# monthrange 정확도
|
|
for m, expected_last in [(1, 31), (2, 28), (4, 30), (12, 31)]:
|
|
_, last = monthrange(2026, m)
|
|
assert last == expected_last, f"{m}월 마지막 날 오류: {last} (기대: {expected_last})"
|
|
# 2024년 2월은 윤년
|
|
_, last_feb_2024 = monthrange(2024, 2)
|
|
assert last_feb_2024 == 29, f"2024년 2월 오류: {last_feb_2024}"
|
|
print(f" OK monthrange 정확도 검증 (윤년 포함)")
|
|
|
|
# 기간 범위 생성 검증
|
|
year, month = 2026, 5
|
|
_, last_day = monthrange(year, month)
|
|
start_dt = datetime(year, month, 1, 0, 0, 0)
|
|
end_dt = datetime(year, month, last_day, 23, 59, 59)
|
|
assert start_dt.day == 1, "시작일 오류"
|
|
assert end_dt.day == 31, "종료일 오류 (5월)"
|
|
assert (end_dt - start_dt).days == 30, "기간 오류"
|
|
print(f" OK 기간 범위: {start_dt.date()} ~ {end_dt.date()}")
|
|
|
|
except AssertionError as e:
|
|
print(f" ERR {e}")
|
|
ok = False
|
|
|
|
print("\n=== E-1 월별 리포트 자동 생성 테스트 완료 ===")
|
|
if ok:
|
|
print("모든 검사 통과")
|
|
else:
|
|
sys.exit(1)
|