""" Admin API Authentication Tests pytest tests for admin authentication middleware in admin_poi.py Tests: - ADMIN_API_KEY 검증 로직 - 인증 없이 접근 시 401 에러 - 잘못된 키 접근 시 403 에러 - 키 미설정 시 503 에러 - 타이밍 공격 방지 (상수 시간 비교) """ import pytest import os import secrets from unittest.mock import patch from httpx import AsyncClient # ============================================ # Admin API Key Verification Tests # ============================================ @pytest.mark.anyio async def test_admin_preview_without_api_key(client: AsyncClient): """ 인증 없이 Admin API 접근 시 401 에러 X-Admin-API-Key 헤더 없이 요청 """ response = await client.get("/admin/poi/preview/12") # 12 = 애월 # 키가 설정되지 않았거나 헤더가 없으면 401 또는 503 assert response.status_code in [401, 503] if response.status_code == 401: data = response.json() assert data["detail"]["code"] == "ADMIN_002" assert "X-Admin-API-Key" in data["detail"]["message"] @pytest.mark.anyio async def test_admin_preview_with_invalid_api_key(client: AsyncClient): """ 잘못된 Admin API 키로 접근 시 403 에러 """ # 환경변수가 설정되어 있어야 403 테스트 가능 if not os.getenv("ADMIN_API_KEY"): pytest.skip("ADMIN_API_KEY not set - skipping invalid key test") invalid_key = "invalid_api_key_12345" response = await client.get( "/admin/poi/preview/12", headers={"X-Admin-API-Key": invalid_key} ) assert response.status_code == 403 data = response.json() assert data["detail"]["code"] == "ADMIN_003" assert "유효하지 않은" in data["detail"]["message"] @pytest.mark.anyio async def test_admin_sync_without_api_key(client: AsyncClient): """ 인증 없이 Admin Sync API 접근 시 401 에러 """ response = await client.post("/admin/poi/sync/12?dry_run=true") assert response.status_code in [401, 503] @pytest.mark.anyio async def test_admin_sync_with_invalid_api_key(client: AsyncClient): """ 잘못된 키로 Admin Sync API 접근 시 403 에러 """ if not os.getenv("ADMIN_API_KEY"): pytest.skip("ADMIN_API_KEY not set") response = await client.post( "/admin/poi/sync/12?dry_run=true", headers={"X-Admin-API-Key": "wrong_key"} ) assert response.status_code == 403 # ============================================ # Service Unavailable Tests (503) # ============================================ @pytest.mark.anyio async def test_admin_api_disabled_when_key_not_configured(): """ ADMIN_API_KEY 환경변수 미설정 시 503 에러 (시뮬레이션) Note: 실제 테스트에서는 환경변수를 변경하기 어려우므로 verify_admin_api_key 함수를 직접 테스트 """ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # 직접 함수 import 후 테스트 from routers.admin_poi import verify_admin_api_key from fastapi import HTTPException # ADMIN_API_KEY가 None인 상황 시뮬레이션 with patch('routers.admin_poi.ADMIN_API_KEY', None): with pytest.raises(HTTPException) as exc_info: await verify_admin_api_key(x_admin_api_key="any_key") assert exc_info.value.status_code == 503 assert exc_info.value.detail["code"] == "ADMIN_001" # ============================================ # Public Endpoints (No Auth Required) # ============================================ @pytest.mark.anyio async def test_get_regions_no_auth_required(client: AsyncClient): """ 지역 목록 조회 API는 인증 불필요 """ response = await client.get("/admin/poi/regions") assert response.status_code == 200 data = response.json() assert isinstance(data, list) # 애월(12)이 목록에 있는지 확인 region_codes = [r["code"] for r in data] assert "12" in region_codes # 지역 이름 확인 aewol = next((r for r in data if r["code"] == "12"), None) assert aewol is not None assert aewol["name"] == "애월" @pytest.mark.anyio async def test_get_categories_no_auth_required(client: AsyncClient): """ 카테고리 목록 조회 API는 인증 불필요 """ response = await client.get("/admin/poi/categories") assert response.status_code == 200 data = response.json() assert isinstance(data, list) # 필수 카테고리 확인 category_codes = [c["code"] for c in data] assert "c1" in category_codes # 관광지 assert "c4" in category_codes # 음식점 @pytest.mark.anyio async def test_get_poi_stats_no_auth_required(client: AsyncClient): """ POI 통계 조회 API는 인증 불필요 """ response = await client.get("/admin/poi/stats") assert response.status_code == 200 data = response.json() assert "total_spots" in data assert "visitjeju_spots" in data assert "by_category" in data # ============================================ # Region and Category Validation Tests # ============================================ @pytest.mark.anyio async def test_preview_invalid_region_code(client: AsyncClient): """ 유효하지 않은 지역 코드로 preview 요청 시 400 에러 """ # 인증 스킵을 위해 환경변수 확인 admin_key = os.getenv("ADMIN_API_KEY") if not admin_key: pytest.skip("ADMIN_API_KEY not set") response = await client.get( "/admin/poi/preview/99", # 존재하지 않는 지역 코드 headers={"X-Admin-API-Key": admin_key} ) assert response.status_code == 400 assert "Invalid region code" in response.json()["detail"] @pytest.mark.anyio async def test_preview_invalid_category(client: AsyncClient): """ 유효하지 않은 카테고리로 preview 요청 시 400 에러 """ admin_key = os.getenv("ADMIN_API_KEY") if not admin_key: pytest.skip("ADMIN_API_KEY not set") response = await client.get( "/admin/poi/preview/12?category=invalid", headers={"X-Admin-API-Key": admin_key} ) assert response.status_code == 400 assert "Invalid category" in response.json()["detail"] @pytest.mark.anyio async def test_sync_invalid_region_code(client: AsyncClient): """ 유효하지 않은 지역 코드로 sync 요청 시 400 에러 """ admin_key = os.getenv("ADMIN_API_KEY") if not admin_key: pytest.skip("ADMIN_API_KEY not set") response = await client.post( "/admin/poi/sync/invalid?dry_run=true", headers={"X-Admin-API-Key": admin_key} ) assert response.status_code == 400 # ============================================ # Timing Attack Prevention Tests # ============================================ def test_constant_time_comparison(): """ 타이밍 공격 방지를 위한 상수 시간 비교 테스트 secrets.compare_digest가 사용되었는지 확인 (실제 타이밍 차이 측정은 환경에 따라 다르므로 함수 사용 여부만 확인) """ correct_key = "correct_admin_key_12345" wrong_key = "wrong_admin_key_12345" # secrets.compare_digest는 길이가 같은 문자열에 대해 # 상수 시간 비교를 수행 result1 = secrets.compare_digest(correct_key, correct_key) result2 = secrets.compare_digest(correct_key, wrong_key) assert result1 is True assert result2 is False # 첫 글자만 다른 경우 almost_correct = "aorrect_admin_key_12345" result3 = secrets.compare_digest(correct_key, almost_correct) assert result3 is False # ============================================ # Helper Function Tests # ============================================ def test_parse_tags(): """태그 문자열 파싱 테스트""" import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from routers.admin_poi import parse_tags # 정상 케이스 tags = parse_tags("자연, 해변, 힐링") assert tags == ["자연", "해변", "힐링"] # 빈 문자열 assert parse_tags("") == [] # None assert parse_tags(None) == [] # 공백 포함 tags = parse_tags(" 자연 , 해변 ") assert tags == ["자연", "해변"] def test_infer_themes(): """테마 추론 테스트""" import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from routers.admin_poi import infer_themes # 역사 테마 themes = infer_themes(["역사", "유적지", "문화"]) assert "history" in themes # 자연 테마 themes = infer_themes(["바다", "해변", "자연경관"]) assert "nature" in themes # 음식 테마 themes = infer_themes(["맛집", "카페", "먹거리"]) assert "food" in themes # 사진 테마 themes = infer_themes(["포토스팟", "일몰", "뷰맛집"]) assert "photo" in themes # 힐링 테마 themes = infer_themes(["힐링", "휴식", "명상"]) assert "healing" in themes # 태그 없을 때 기본값 nature themes = infer_themes([]) assert themes == ["nature"] def test_infer_activity_level(): """활동 레벨 추론 테스트""" import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from routers.admin_poi import infer_activity_level # active assert infer_activity_level(["트레킹", "등산"]) == "active" assert infer_activity_level(["올레길", "하이킹"]) == "active" # moderate assert infer_activity_level(["산책", "걷기"]) == "moderate" # light (기본값) assert infer_activity_level([]) == "light" assert infer_activity_level(["카페", "음식"]) == "light" def test_infer_mood(): """분위기 추론 테스트""" import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from routers.admin_poi import infer_mood # quiet moods = infer_mood(["조용한", "한적한"]) assert "quiet" in moods # vibrant moods = infer_mood(["활기찬", "축제"]) assert "vibrant" in moods # romantic moods = infer_mood(["로맨틱", "데이트"]) assert "romantic" in moods # family moods = infer_mood(["가족", "어린이"]) assert "family" in moods # 기본값 moods = infer_mood([]) assert moods == ["quiet"] def test_infer_time_of_day(): """시간대 추론 테스트""" import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from routers.admin_poi import infer_time_of_day # morning times = infer_time_of_day(["일출", "아침"]) assert "morning" in times # evening times = infer_time_of_day(["일몰", "야경"]) assert "evening" in times # 기본값 afternoon times = infer_time_of_day([]) assert times == ["afternoon"] def test_normalize_phone(): """전화번호 정규화 테스트""" import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from routers.admin_poi import normalize_phone # 정상 번호 assert normalize_phone("064-123-4567") == "064-123-4567" # 빈 값 assert normalize_phone(None) is None assert normalize_phone("") is None assert normalize_phone(" ") is None assert normalize_phone("--") is None def test_infer_restaurant_category(): """음식점 카테고리 세분화 테스트""" import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from routers.admin_poi import infer_restaurant_category # 카페 구분 assert infer_restaurant_category(["카페", "커피"]) == "cafe" assert infer_restaurant_category(["디저트", "베이커리"]) == "cafe" # 일반 음식점 assert infer_restaurant_category(["한식", "맛집"]) == "restaurant" assert infer_restaurant_category([]) == "restaurant" def test_infer_category_from_tags(): """관광지 카테고리 세분화 테스트""" import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from routers.admin_poi import infer_category_from_tags # 해변 assert infer_category_from_tags(["해변", "해수욕장"], "coastline") == "beach" # 오름 assert infer_category_from_tags(["오름", "산"], "coastline") == "oreum" # 숲 assert infer_category_from_tags(["숲", "곶자왈"], "coastline") == "forest" # 4.3 유적 assert infer_category_from_tags(["4.3", "역사"], "coastline") == "jeju43" # 기본값 유지 assert infer_category_from_tags(["일반태그"], "coastline") == "coastline"