feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)

설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)

데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
  portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)

백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
  deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
  /portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정

프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
  계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기

테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 10:26:51 +09:00
parent dc128ae4cf
commit b4a1fc79ab
52 changed files with 5614 additions and 96 deletions

View File

@@ -0,0 +1,202 @@
"""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