"""API 키 인증 미들웨어 (#1). 앱 전체를 띄우지 않고 순수 함수로 검증한다. 엔진 기동(코퍼스 인덱싱)을 타면 테스트가 느려지고 인증 로직과 무관한 이유로 깨진다. """ from __future__ import annotations import pytest from app.core.config import Settings from app.main import ( AuthConfigurationError, is_authorized, public_paths, validate_auth_settings, ) def _settings(**overrides) -> Settings: base = {"api_key": "", "require_api_key": False, "public_health": True, "public_docs": True} base.update(overrides) return Settings(**base) # --------------------------------------------------------------------------- # fail-closed 기동 검증 # --------------------------------------------------------------------------- def test_require_api_key_without_key_fails_startup(): with pytest.raises(AuthConfigurationError) as exc: validate_auth_settings(_settings(require_api_key=True, api_key="")) assert "REQUIRE_API_KEY" in str(exc.value) def test_require_api_key_with_whitespace_only_key_fails(): with pytest.raises(AuthConfigurationError): validate_auth_settings(_settings(require_api_key=True, api_key=" ")) def test_require_api_key_with_key_starts_fine(): validate_auth_settings(_settings(require_api_key=True, api_key="secret")) def test_default_settings_start_without_key(): """기본값(개발)에서는 기동을 막지 않는다 — 기존 동작 보존.""" validate_auth_settings(_settings()) # --------------------------------------------------------------------------- # 기존 기본 동작 보존 # --------------------------------------------------------------------------- def test_no_key_configured_allows_everything(): s = _settings(api_key="") assert is_authorized(s, "/v1/plagiarism/detect", "") is True assert is_authorized(s, "/v1/corpus", "") is True def test_key_configured_rejects_missing_and_wrong_key(): s = _settings(api_key="secret") assert is_authorized(s, "/v1/plagiarism/detect", "") is False assert is_authorized(s, "/v1/plagiarism/detect", "wrong") is False assert is_authorized(s, "/v1/plagiarism/detect", "secret") is True def test_corpus_write_paths_are_protected(): s = _settings(api_key="secret") for path in ("/v1/corpus", "/v1/corpus/file", "/v1/corpus/doc-1", "/v1/plagiarism/batch"): assert is_authorized(s, path, "") is False, path # --------------------------------------------------------------------------- # 공개 경로 설정 # --------------------------------------------------------------------------- def test_health_public_by_default(): s = _settings(api_key="secret") assert "/v1/health" in public_paths(s) assert is_authorized(s, "/v1/health", "") is True def test_health_can_be_protected(): s = _settings(api_key="secret", public_health=False) assert "/v1/health" not in public_paths(s) assert is_authorized(s, "/v1/health", "") is False assert is_authorized(s, "/v1/health", "secret") is True def test_docs_public_by_default_and_can_be_closed(): s = _settings(api_key="secret") assert "/openapi.json" in public_paths(s) closed = _settings(api_key="secret", public_docs=False) assert "/openapi.json" not in public_paths(closed) for path in ("/docs", "/openapi.json", "/redoc"): assert is_authorized(closed, path, "") is False assert is_authorized(closed, path, "secret") is True def test_root_console_stays_public(): s = _settings(api_key="secret") assert is_authorized(s, "/", "") is True # --------------------------------------------------------------------------- # 회귀: 비ASCII 키가 TypeError 로 500 을 내지 않아야 한다 # --------------------------------------------------------------------------- def test_non_ascii_key_does_not_raise(): s = _settings(api_key="비밀키-한글🔑") assert is_authorized(s, "/v1/plagiarism/detect", "비밀키-한글🔑") is True assert is_authorized(s, "/v1/plagiarism/detect", "틀린키") is False assert is_authorized(s, "/v1/plagiarism/detect", "") is False def test_non_ascii_configured_key_returns_401_not_500(): """서버에 한글 키를 설정해도 500 이 아니라 401 이어야 한다. HTTP 헤더는 비ASCII 를 전송할 수 없으므로 이런 키는 사실상 인증 불가지만, 최소한 서버가 TypeError 로 터지면 안 된다. (운영 키는 ASCII 로 발급할 것) """ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from app.main import is_authorized as guard settings = _settings(api_key="한글키") app = FastAPI() @app.middleware("http") async def auth(request: Request, call_next): if not guard(settings, request.url.path, request.headers.get("x-api-key", "")): return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"}) return await call_next(request) @app.get("/v1/thing") async def thing(): return {"ok": True} with TestClient(app) as client: assert client.get("/v1/thing").status_code == 401 assert client.get("/v1/thing", headers={"x-api-key": "ascii-guess"}).status_code == 401