"""KIS 자격증명 API 통합 테스트 (`kis_credentials.py`, `auth.py` 이관 API, D그룹 격리 환경). - 격리 원칙: `test_multi_tenant.py`와 동일하게 **임시 Postgres 컨테이너**(DATABASE_URL이 `_test`로 끝나야 함, `conftest.py`가 세션 시작 시 가드)에서만 실행한다. 라이브 `autostock-postgres`/`autostock-backend`에는 어떤 쓰기 경로도 호출하지 않는다(구 검수 H4: 이전 버전은 운영 DB를 직접 대상으로 했었음 — 수정됨). - 이벤트 루프/lifespan/스키마 생성/HTTP 클라이언트/`_auth` 헬퍼는 `conftest.py`(이 디렉터리 공용, `test_multi_tenant.py`와 동일)가 제공한다. - 테스트가 생성한 사용자/자격증명은 각 fixture teardown에서 직접 삭제한다. - KIS 실계좌 호출(verify)은 `KoreaInvestmentAPI.for_user`를 monkeypatch 하여 네트워크를 타지 않는다. 실행 예시 (임시 Postgres 컨테이너 안에서, `test_multi_tenant.py` docstring 참고): python -m pytest tests/integration/test_kis_credentials.py -m integration -v """ import time import pytest import pytest_asyncio from sqlalchemy import delete pytestmark = pytest.mark.integration from app.core.database import AsyncSessionLocal # noqa: E402 from app.core.security import _get_api_key, create_session_token # noqa: E402 from app.models.models import User, UserKisCredential # noqa: E402 from app.services.korea_investment_api import KoreaInvestmentAPI # noqa: E402 from app.services.tenant.kis_pool import kis_client_pool # noqa: E402 from app.api.endpoints import kis_credentials as kis_credentials_module # noqa: E402 from .conftest import _auth # noqa: E402 _SUFFIX = str(int(time.time())) class _FakeKis: """`KoreaInvestmentAPI.for_user`가 반환할 가짜 인스턴스 (verify 단위 테스트용).""" def __init__(self, rt_cd="0", raise_exc=None): self._rt_cd = rt_cd self._raise_exc = raise_exc self.closed = False async def get_balance(self): if self._raise_exc: raise self._raise_exc return { "rt_cd": self._rt_cd, "msg1": "정상처리" if self._rt_cd == "0" else "계좌 조회 실패", "output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000", "scts_evlu_amt": "4000000"}], } async def close(self): self.closed = True @pytest_asyncio.fixture async def two_users(): """테스트용 사용자 2명 생성 + 세션 토큰 발급. 종료 시 자격증명/사용자 완전 삭제.""" async with AsyncSessionLocal() as db: u1 = User(username=f"it_kis_a_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False) u2 = User(username=f"it_kis_b_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False) db.add_all([u1, u2]) await db.commit() await db.refresh(u1) await db.refresh(u2) token1, _ = create_session_token(u1.username) token2, _ = create_session_token(u2.username) ids = [u1.id, u2.id] yield {"u1_id": u1.id, "u1_token": token1, "u2_id": u2.id, "u2_token": token2} async with AsyncSessionLocal() as db: await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id.in_(ids))) await db.execute(delete(User).where(User.id.in_(ids))) await db.commit() for uid in ids: await kis_client_pool.invalidate(uid) kis_credentials_module._last_verify_attempt.pop(ids[0], None) kis_credentials_module._last_verify_attempt.pop(ids[1], None) @pytest.mark.asyncio async def test_register_masked_view_duplicate_patch_delete(client, two_users): u1_headers = _auth(two_users["u1_token"]) u2_headers = _auth(two_users["u2_token"]) # 최초 조회 — 미등록 404 r = await client.get("/api/v1/account/credentials", headers=u1_headers) assert r.status_code == 404 body = { "mode": "virtual", "app_key": f"testappkey{_SUFFIX}", "app_secret": "testsecret", "account_no": "1234567801", } r = await client.put("/api/v1/account/credentials", headers=u1_headers, json=body) assert r.status_code == 200, r.text data = r.json() assert data["mode"] == "virtual" assert data["app_key_hint"] == body["app_key"][:6] + "…" assert data["account_masked"] == "******" + "1234567801"[:8][-2:] assert data["is_enabled"] is False assert data["auto_trade_enabled"] is False assert data["last_verified_at"] is None # 평문이 응답에 없어야 함 assert body["app_secret"] not in r.text assert body["app_key"] not in r.text # 마스킹 조회 r = await client.get("/api/v1/account/credentials", headers=u1_headers) assert r.status_code == 200 assert r.json()["account_masked"] == data["account_masked"] # 잘못된 계좌번호 형식 (하이픈 제거 후 10자리 아님) → 422 bad_body = dict(body, account_no="12345") r = await client.put("/api/v1/account/credentials", headers=u1_headers, json=bad_body) assert r.status_code == 422 # 동일 앱키를 다른 사용자가 등록 → 409 dup_body = dict(body, account_no="9876543201") r = await client.put("/api/v1/account/credentials", headers=u2_headers, json=dup_body) assert r.status_code == 409 # 검증 전 자동매매 활성화 시도 → 400 r = await client.patch("/api/v1/account/auto-trade", headers=u1_headers, json={"enabled": True}) assert r.status_code == 400 # 자동매매 비활성화 요청은 검증 여부와 무관하게 허용 r = await client.patch("/api/v1/account/auto-trade", headers=u1_headers, json={"enabled": False}) assert r.status_code == 200 assert r.json()["auto_trade_enabled"] is False # 삭제 → 204, 이후 조회 404 r = await client.delete("/api/v1/account/credentials", headers=u1_headers) assert r.status_code == 204 r = await client.get("/api/v1/account/credentials", headers=u1_headers) assert r.status_code == 404 @pytest.mark.asyncio async def test_verify_success_failure_and_cooldown(client, two_users, monkeypatch): headers = _auth(two_users["u1_token"]) body = { "mode": "virtual", "app_key": f"verifykey{_SUFFIX}", "app_secret": "s", "account_no": "1122334401", } r = await client.put("/api/v1/account/credentials", headers=headers, json=body) assert r.status_code == 200 # 성공 케이스 monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="0"))) r = await client.post("/api/v1/account/credentials/verify", headers=headers) assert r.status_code == 200, r.text data = r.json() assert data["ok"] is True assert data["balance_summary"]["total_assets"] == 5000000.0 # 60초 쿨다운 이내 재시도 → 429 r = await client.post("/api/v1/account/credentials/verify", headers=headers) assert r.status_code == 429 # 쿨다운 우회 후 실패 케이스(rt_cd != 0) → 400 kis_credentials_module._last_verify_attempt.pop(two_users["u1_id"], None) monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="1"))) r = await client.post("/api/v1/account/credentials/verify", headers=headers) assert r.status_code == 400 # 검증 성공 후에는 자동매매 활성화 허용 kis_credentials_module._last_verify_attempt.pop(two_users["u1_id"], None) monkeypatch.setattr(KoreaInvestmentAPI, "for_user", classmethod(lambda cls, **kw: _FakeKis(rt_cd="0"))) r = await client.post("/api/v1/account/credentials/verify", headers=headers) assert r.status_code == 200 r = await client.patch("/api/v1/account/auto-trade", headers=headers, json={"enabled": True}) assert r.status_code == 200 assert r.json()["auto_trade_enabled"] is True @pytest.mark.asyncio async def test_api_key_caller_maps_to_legacy_owner_or_400(client): """API 키(X-API-Key=SECRET_KEY) 호출자는 legacy owner에 매핑되거나(admin 존재), admin이 전혀 없으면 400을 반환한다 — 어느 쪽이든 500이 아니어야 한다.""" r = await client.get("/api/v1/account/credentials", headers=_auth(_get_api_key())) assert r.status_code in (200, 400, 404) @pytest.mark.asyncio async def test_admin_tenant_status_requires_admin(client, two_users): # 일반 사용자는 관리자 API 접근 불가 r = await client.get("/api/v1/auth/admin/tenant/status", headers=_auth(two_users["u1_token"])) assert r.status_code == 403 # API 키(SECRET_KEY)는 관리자로 간주되어 접근 가능 r = await client.get("/api/v1/auth/admin/tenant/status", headers=_auth(_get_api_key())) assert r.status_code == 200 data = r.json() assert "multi_tenant_enabled" in data assert "null_user_id_counts" in data