Files
auto-stock/backend/tests/integration/test_multi_tenant.py
shuim b4a1fc79ab 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>
2026-09-21 10:26:51 +09:00

965 lines
49 KiB
Python

"""멀티테넌트(사용자별 매매) 1단계 통합 테스트 (D그룹, 격리 환경).
`docs/multi_tenant_design.md` §7 "테스트 전략" (a)~(h) 대응.
- 격리 원칙: 반드시 **임시 Postgres 컨테이너**(DATABASE_URL을 그 컨테이너로 지정)에서
실행한다. 라이브 `autostock-postgres`/`autostock-backend`/`autostock-redis`에는
어떤 쓰기·주문 경로도 호출하지 않는다. Redis는 `fakeredis`로 대체하고,
`KoreaInvestmentAPI`의 주문/잔고/`for_user` 계열은 전부 monkeypatch 로 가짜 구현을
주입한다(가드: `test_00_mocks_applied_guard`).
- 이벤트 루프/lifespan/스키마 생성/HTTP 클라이언트/DB 가드는 `conftest.py`(H4, 이 디렉터리
공용)가 제공한다 — `settings.DATABASE_URL`이 `_test`로 끝나지 않으면 세션 시작 시
`pytest.exit()`.
- 실행 예시 (임시 컨테이너 안에서):
docker run --rm --network host \
-v $(pwd)/backend:/app -w /app \
-e DATABASE_URL=postgresql://autostock:test@127.0.0.1:15499/autostock_test \
-e SECRET_KEY=test-secret-key-for-integration-tests-32chars \
-e CREDENTIALS_ENCRYPTION_KEY=test-cred-key-for-integration-tests-32c \
-e BOOTSTRAP_ADMIN_PASSWORD=bootstrap-pw \
auto-stock-backend:latest \
bash -lc "pip install -q fakeredis pytest-asyncio httpx && python -m pytest tests/integration/test_multi_tenant.py -m integration -v"
"""
import asyncio
import time
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from sqlalchemy import delete, text
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 ( # noqa: E402
ActiveTrade,
Portfolio,
StockBanV2,
TradeHistory,
User,
UserKisCredential,
)
from app.services import db_worker as db_worker_module # noqa: E402
from app.services.db_worker import ( # noqa: E402
db_writer_worker,
enqueue_stock_ban_upsert_v2,
)
from app.services.korea_investment_api import KoreaInvestmentAPI # noqa: E402
from app.services.loss_tracker import ConsecutiveLossTracker # noqa: E402
from app.services.tenant import bootstrap as tenant_bootstrap # noqa: E402
from app.services.tenant.kis_pool import kis_client_pool # noqa: E402
from app.services.tenant.registry import user_context_registry # noqa: E402
from app.services.tenant.context import UserContext # noqa: E402
from app.services import trade_executor as trade_executor_module # noqa: E402
from app.core import redis as redis_module # 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()))
# ─────────────────────────────────────────────────────────────────────────────
# 가드 — mock이 실제로 적용됐는지 매 테스트 전에 확인 (§지시사항 필수 가드)
# ─────────────────────────────────────────────────────────────────────────────
class _FakeKis:
"""`KoreaInvestmentAPI` 대체 — 네트워크 호출을 절대 하지 않는다."""
def __init__(self, rt_cd="0", balance=None, order_id="FAKE0001"):
self._rt_cd = rt_cd
self._balance = balance or {
"rt_cd": "0",
"output1": [],
"output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000", "scts_evlu_amt": "4000000"}],
}
self._order_id = order_id
self.place_order_calls = []
self.closed = False
async def get_balance(self):
return self._balance
async def get_overseas_balance(self):
return {"rt_cd": "0", "output1": [], "output2": [{}]}
async def get_current_price(self, stock_code):
return {"output": {"stck_prpr": "10000", "hts_kor_isnm": f"종목{stock_code}"}}
async def place_order(self, stock_code, order_type, quantity, price=None):
self.place_order_calls.append(
{"stock_code": stock_code, "order_type": order_type, "quantity": quantity, "price": price}
)
return {"rt_cd": self._rt_cd, "msg1": "정상처리", "output": {"ODNO": self._order_id}}
async def cancel_order(self, order_id, quantity):
return {"rt_cd": "0", "msg1": "정상처리"}
async def close(self):
self.closed = True
class _FakeMddAlwaysOpen:
async def can_open_new_position(self) -> bool:
return True
class _FakeLossTrackerNeverBanned:
async def is_banned_safe(self, stock_code: str) -> bool:
return False
@pytest.fixture(autouse=True)
def _guard_no_real_kis(monkeypatch):
"""모든 테스트에서 실제 KIS 호출 경로를 차단 — 클래스 메서드 자체를 막아 우회 불가하게 함."""
async def _blocked(*args, **kwargs):
raise AssertionError("실제 KIS API 호출이 시도되었습니다 — 테스트에서는 반드시 mock 되어야 합니다")
monkeypatch.setattr(KoreaInvestmentAPI, "place_order", _blocked, raising=False)
monkeypatch.setattr(KoreaInvestmentAPI, "place_overseas_order", _blocked, raising=False)
yield
def test_00_mocks_applied_guard(monkeypatch):
"""가드: `safe_place_order_v2`가 실제로 `ctx.kis`(fake)를 호출하고, 전역
`KoreaInvestmentAPI.place_order`는 호출되지 않는지 사전 확인."""
fake = _FakeKis()
assert asyncio.run(fake.place_order("005930", "BUY", 1)) is not None
# 전역 클래스 메서드는 fixture에서 차단됨 — 직접 호출하면 AssertionError
inst = KoreaInvestmentAPI.__new__(KoreaInvestmentAPI)
with pytest.raises(AssertionError):
asyncio.run(KoreaInvestmentAPI.place_order(inst, "005930", "BUY", 1))
# ─────────────────────────────────────────────────────────────────────────────
# 공용 fixture
# ─────────────────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture
async def two_users():
"""테스트용 사용자 2명(A=admin/legacy owner, B=일반) 생성 + 세션 토큰. 종료 시 완전 삭제."""
async with AsyncSessionLocal() as db:
u_a = User(username=f"mt_a_{_SUFFIX}", password_hash="x", is_active=True, is_admin=True)
u_b = User(username=f"mt_b_{_SUFFIX}", password_hash="x", is_active=True, is_admin=False)
db.add_all([u_a, u_b])
await db.commit()
await db.refresh(u_a)
await db.refresh(u_b)
token_a, _ = create_session_token(u_a.username)
token_b, _ = create_session_token(u_b.username)
ids = [u_a.id, u_b.id]
yield {"a_id": u_a.id, "a_token": token_a, "a_user": u_a.username, "b_id": u_b.id, "b_token": token_b}
async with AsyncSessionLocal() as db:
await db.execute(delete(StockBanV2).where(StockBanV2.user_id.in_(ids)))
await db.execute(delete(ActiveTrade).where(ActiveTrade.user_id.in_(ids)))
await db.execute(delete(TradeHistory).where(TradeHistory.user_id.in_(ids)))
await db.execute(delete(Portfolio).where(Portfolio.user_id.in_(ids)))
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)
await user_context_registry.invalidate(uid)
kis_credentials_module._last_verify_attempt.pop(ids[0], None)
kis_credentials_module._last_verify_attempt.pop(ids[1], None)
async def _run_worker_and_wait(task: dict, check, timeout: float = 5.0):
"""db_writer_worker를 짧게 기동해 task 1건을 처리시키고, `check()`가 True가
될 때까지 폴링한 뒤 워커를 취소한다.
db_writer_worker는 무한 루프 + task_done() 미호출 구조라 queue.join()으로
동기화할 수 없으므로, 결과가 DB에 반영됐는지 폴링으로 확인한다.
"""
queue = db_worker_module.get_db_task_queue()
worker = asyncio.create_task(db_writer_worker())
try:
await queue.put(task)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if await check():
return
await asyncio.sleep(0.05)
raise AssertionError(f"db_worker task {task.get('type')} 결과를 {timeout}s 내에 확인하지 못함")
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
# ─────────────────────────────────────────────────────────────────────────────
# (a) 두 사용자 register/login → PUT /account/credentials → 마스킹 조회 → 동일 앱키 409
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_a_register_login_credentials_duplicate_appkey(client, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "SIGNUP_ENABLED", True)
monkeypatch.setattr(settings, "SIGNUP_AUTO_APPROVE", True)
uname1 = f"mt_reg_a_{_SUFFIX}"
uname2 = f"mt_reg_b_{_SUFFIX}"
try:
r = await client.post("/api/v1/auth/register", json={"username": uname1, "password": "Passw0rd!123"})
assert r.status_code == 201, r.text
r = await client.post("/api/v1/auth/register", json={"username": uname2, "password": "Passw0rd!123"})
assert r.status_code == 201, r.text
r = await client.post("/api/v1/auth/login", json={"username": uname1, "password": "Passw0rd!123"})
assert r.status_code == 200, r.text
token1 = r.json()["token"]
r = await client.post("/api/v1/auth/login", json={"username": uname2, "password": "Passw0rd!123"})
assert r.status_code == 200, r.text
token2 = r.json()["token"]
h1, h2 = _auth(token1), _auth(token2)
body = {"mode": "virtual", "app_key": f"regtestkey{_SUFFIX}", "app_secret": f"regsecret{_SUFFIX}", "account_no": "1111111101"}
r = await client.put("/api/v1/account/credentials", headers=h1, json=body)
assert r.status_code == 200, r.text
data = r.json()
assert data["app_key_hint"] == body["app_key"][:6] + "…"
assert body["app_key"] not in r.text and body["app_secret"] not in r.text
r = await client.get("/api/v1/account/credentials", headers=h1)
assert r.status_code == 200
assert r.json()["app_key_hint"] == data["app_key_hint"]
dup = dict(body, account_no="2222222201")
r = await client.put("/api/v1/account/credentials", headers=h2, json=dup)
assert r.status_code == 409
finally:
async with AsyncSessionLocal() as db:
uids_res = await db.execute(select_users_by_username(uname1, uname2))
uids = [row[0] for row in uids_res]
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id.in_(uids)))
await db.execute(delete(User).where(User.id.in_(uids)))
await db.commit()
for uid in uids:
await kis_client_pool.invalidate(uid)
def select_users_by_username(*usernames):
from sqlalchemy import select as _select
return _select(User.id).where(User.username.in_(usernames))
# ─────────────────────────────────────────────────────────────────────────────
# (b) upsert_portfolio — user_id A/B로 동일 종목 2행 공존 (0012 이전 알려진 제약 → xfail)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@pytest.mark.xfail(
strict=True,
reason=(
"설계 §3.2/§7: portfolio.stock_code 는 0012 마이그레이션 전까지 전역 unique 유지 — "
"서로 다른 user_id로 동일 stock_code를 upsert하면 두 번째 INSERT가 UniqueViolation으로 실패한다."
),
)
async def test_b_upsert_portfolio_same_stock_two_users_conflict(two_users):
stock_code = f"9{_SUFFIX[-5:]}" # 6자리 임시 코드
async def _row_exists(user_id: int) -> bool:
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
res = await db.execute(
_select(Portfolio).where(Portfolio.user_id == user_id, Portfolio.stock_code == stock_code)
)
return res.scalar_one_or_none() is not None
await _run_worker_and_wait(
{
"type": "upsert_portfolio",
"data": {
"user_id": two_users["a_id"], "stock_code": stock_code, "stock_name": "테스트",
"quantity": 10, "average_price": 1000, "current_price": 1000,
"market": "KRX", "strategy_type": "manual",
},
},
lambda: _row_exists(two_users["a_id"]),
)
# 서로 다른 user_id로 동일 stock_code upsert 시도 — unique(stock_code) 위반으로 두 번째 행이 생기지 않아야
# "성공"(xfail 대상)하지만, 실제로는 IntegrityError 로 조용히 실패(예외를 삼키고 재시도 로직을 태움)한다.
await _run_worker_and_wait(
{
"type": "upsert_portfolio",
"data": {
"user_id": two_users["b_id"], "stock_code": stock_code, "stock_name": "테스트",
"quantity": 5, "average_price": 2000, "current_price": 2000,
"market": "KRX", "strategy_type": "manual",
},
},
lambda: _row_exists(two_users["b_id"]),
timeout=2.0,
)
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
res = await db.execute(_select(Portfolio).where(Portfolio.stock_code == stock_code))
rows = res.scalars().all()
assert len(rows) == 2, "두 사용자가 동일 종목을 공존 보유해야 한다 (0012 이전에는 실패가 알려진 제약)"
# ─────────────────────────────────────────────────────────────────────────────
# (c) upsert_stock_ban_v2 — 사용자별 ban 분리 + load_from_db_v2 복원
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_c_stock_ban_v2_upsert_tz_aware_datetime_bug(two_users):
"""실제 loss_tracker 경로(tz-aware ban_until)로 upsert_stock_ban_v2를 태워도
db_worker가 저장 직전 naive-UTC로 정규화해 DB write가 성공해야 한다
(수정 전에는 asyncpg DataError로 DLQ에 빠졌던 결함)."""
stock_code = "005930"
ban_until_a = datetime.now(timezone.utc) + timedelta(days=42) # 실제 코드와 동일하게 tz-aware
async def _ban_exists(user_id: int) -> bool:
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
res = await db.execute(
_select(StockBanV2).where(StockBanV2.user_id == user_id, StockBanV2.stock_code == stock_code)
)
return res.scalar_one_or_none() is not None
await enqueue_stock_ban_upsert_v2(two_users["a_id"], stock_code, 3, ban_until_a)
worker = asyncio.create_task(db_writer_worker())
try:
deadline = time.monotonic() + 8 # 지수 백오프(1→2→4s) 3회 재시도를 기다림
while time.monotonic() < deadline and not await _ban_exists(two_users["a_id"]):
await asyncio.sleep(0.1)
assert await _ban_exists(two_users["a_id"])
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_c_stock_ban_v2_load_from_db_v2_per_user_isolation(two_users):
"""읽기 경로(load_from_db_v2)만 검증 — 위 결함(tz-aware 쓰기)과 독립적으로,
tz-naive 값을 직접 INSERT해 사용자별 격리가 올바른지 확인한다."""
stock_code = "005930"
ban_until_a = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=42)
async with AsyncSessionLocal() as db:
db.add(StockBanV2(user_id=two_users["a_id"], stock_code=stock_code, consecutive_losses=3, ban_until=ban_until_a))
await db.commit()
tracker_a = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
tracker_b = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
async with AsyncSessionLocal() as db:
await tracker_a.load_from_db_v2(db, two_users["a_id"])
await tracker_b.load_from_db_v2(db, two_users["b_id"])
assert await tracker_a.is_banned_safe(stock_code) is True
assert await tracker_b.is_banned_safe(stock_code) is False
# ─────────────────────────────────────────────────────────────────────────────
# (c-legacy) save_to_db() → load_from_db() 라운드트립 — 레거시 stock_bans도
# tz-aware ban_until을 안전하게 저장·복원해야 한다 (db_worker.py fix 회귀 방지)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_c_legacy_stock_ban_save_load_roundtrip_tz_aware():
from app.models.models import StockBan
stock_code = f"8{_SUFFIX[-5:]}" # 6자리 임시 코드, stock_bans PK 충돌 회피
async def _ban_row(db):
from sqlalchemy import select as _select
res = await db.execute(_select(StockBan).where(StockBan.stock_code == stock_code))
return res.scalar_one_or_none()
tracker = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
tracker.record_trade_result(stock_code, is_loss=True)
tracker.record_trade_result(stock_code, is_loss=True)
tracker.record_trade_result(stock_code, is_loss=True) # 3연패 → tz-aware ban_until 생성
worker = asyncio.create_task(db_writer_worker())
try:
await tracker.save_to_db(stock_code=stock_code)
deadline = time.monotonic() + 5.0
row = None
while time.monotonic() < deadline:
async with AsyncSessionLocal() as db:
row = await _ban_row(db)
if row is not None:
break
await asyncio.sleep(0.05)
assert row is not None, "save_to_db()가 stock_bans에 tz-aware ban_until을 정규화해 저장해야 한다"
assert row.ban_until.tzinfo is None, "stock_bans.ban_until은 naive-UTC로 저장돼야 한다"
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
reloaded = ConsecutiveLossTracker(ban_count=3, ban_calendar_days=42)
async with AsyncSessionLocal() as db:
await reloaded.load_from_db(db)
assert reloaded.is_banned(stock_code) is True
async with AsyncSessionLocal() as db:
await db.execute(delete(StockBan).where(StockBan.stock_code == stock_code))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# (d) safe_place_order_v2 락 키 — fakeredis로 user_id별 키가 다르게 생성되는지
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_d_order_lock_key_namespaced_by_user(monkeypatch, two_users):
fakeredis = pytest.importorskip("fakeredis")
fake_client = fakeredis.aioredis.FakeRedis(decode_responses=True)
monkeypatch.setattr(redis_module, "_redis_client", fake_client)
ctx_a = UserContext(
user_id=two_users["a_id"], username="a", mode="virtual",
kis=_FakeKis(), mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
)
ctx_b = UserContext(
user_id=two_users["b_id"], username="b", mode="virtual",
kis=_FakeKis(), mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
)
seen_keys = []
orig_set = fake_client.set
async def _tracking_set(key, *a, **kw):
seen_keys.append(key)
return await orig_set(key, *a, **kw)
monkeypatch.setattr(fake_client, "set", _tracking_set)
r_a = await trade_executor_module.safe_place_order_v2(ctx_a, "005930", "BUY", 1)
r_b = await trade_executor_module.safe_place_order_v2(ctx_b, "005930", "BUY", 1)
assert r_a["rt_cd"] == "0" and r_b["rt_cd"] == "0"
assert ctx_a.kis.place_order_calls and ctx_b.kis.place_order_calls
expected_a = f"order_lock:{two_users['a_id']}:005930:BUY"
expected_b = f"order_lock:{two_users['b_id']}:005930:BUY"
assert expected_a in seen_keys
assert expected_b in seen_keys
assert expected_a != expected_b
await fake_client.aclose()
# ─────────────────────────────────────────────────────────────────────────────
# (e) MULTI_TENANT_ENABLED=False 에서 GET /portfolio/v2 == GET /portfolio (레거시 호환)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_e_legacy_parity_portfolio_v2_matches_portfolio(client, monkeypatch):
from app.core.config import settings
from app.api.endpoints import portfolio as portfolio_module
from app.services import korea_investment_api as kis_module
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", False)
# effective_user_id가 legacy owner id를 반환하려면 admin(=legacy owner)이 최소 1명 있어야 함
# (설계상 admin 0명이면 400 — 이 테스트는 그 케이스가 아니라 "정상 legacy 경로"를 검증)
async with AsyncSessionLocal() as db:
legacy_admin = User(username=f"mt_legacy_owner_{_SUFFIX}", password_hash="x", is_active=True, is_admin=True)
db.add(legacy_admin)
await db.commit()
await db.refresh(legacy_admin)
legacy_admin_id = legacy_admin.id
# 이전 테스트가 캐싱한 legacy owner_id(0/None)를 무효화 — invalidate_all()은
# 사용자별 캐시만 지우고 legacy 캐시(TTL 60s)는 건드리지 않으므로 직접 리셋한다.
user_context_registry._legacy_ctx = None
user_context_registry._legacy_checked_at = 0.0
async def _fake_get_current_price(self, code):
return {"output": {"stck_prpr": "55000", "hts_kor_isnm": "삼성전자우"}}
monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", _fake_get_current_price, raising=False)
stock_code = "005935"
fake_balance = {
"rt_cd": "0",
"output1": [
{
"pdno": stock_code, "hldg_qty": "3", "pchs_avg_pric": "50000",
"prpr": "55000", "evlu_pfls_amt": "15000", "evlu_pfls_rt": "10.0",
"prdt_name": "삼성전자우",
}
],
"output2": [{"dnca_tot_amt": "1000000", "tot_evlu_amt": "5000000"}],
}
async def _fake_get_balance():
return fake_balance
async def _fake_get_overseas_balance():
return {"rt_cd": "0", "output1": [], "output2": [{}]}
monkeypatch.setattr(kis_module.korea_investment_api, "get_balance", _fake_get_balance)
monkeypatch.setattr(kis_module.korea_investment_api, "get_overseas_balance", _fake_get_overseas_balance)
# 캐시를 강제로 비워 이번 호출에서 반드시 재동기화되게 함
portfolio_module.portfolio_cache.holdings = []
portfolio_module.portfolio_cache.last_synced = None
portfolio_module.portfolio_cache.mark_changed()
try:
r_legacy = await client.get("/api/v1/portfolio", headers=_auth(_get_api_key()))
assert r_legacy.status_code == 200, r_legacy.text
legacy_rows = sorted(r_legacy.json(), key=lambda h: h["stock_code"])
r_v2 = await client.get("/api/v1/portfolio/v2", headers=_auth(_get_api_key()))
assert r_v2.status_code == 200, r_v2.text
v2_rows = sorted(r_v2.json(), key=lambda h: h["stock_code"])
assert [h["stock_code"] for h in legacy_rows] == [h["stock_code"] for h in v2_rows]
assert [h["quantity"] for h in legacy_rows] == [h["quantity"] for h in v2_rows]
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
await db.execute(delete(User).where(User.id == legacy_admin_id))
await db.commit()
portfolio_module.portfolio_cache.holdings = []
portfolio_module.portfolio_cache.mark_changed()
await user_context_registry.invalidate_all()
# ─────────────────────────────────────────────────────────────────────────────
# (f) GET /portfolio/v2 — 사용자별 격리 + 관리자 ?user_id= 열람 + 비관리자 403
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_f_portfolio_v2_isolation_and_admin_override(client, monkeypatch, two_users):
from app.core.config import settings
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
async with AsyncSessionLocal() as db:
db.add(Portfolio(
user_id=two_users["a_id"], stock_code="000660", stock_name="A종목",
quantity=5, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
db.add(Portfolio(
user_id=two_users["b_id"], stock_code="035420", stock_name="B종목",
quantity=7, average_price=2000, current_price=2000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
await db.commit()
try:
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["a_token"]))
assert r.status_code == 200
codes_a = {h["stock_code"] for h in r.json()}
assert "000660" in codes_a
assert "035420" not in codes_a # B의 행이 보이지 않음
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["b_token"]))
codes_b = {h["stock_code"] for h in r.json()}
assert "035420" in codes_b
assert "000660" not in codes_b
# 관리자(A)가 ?user_id=B 로 조회 — 허용
r = await client.get(
f"/api/v1/portfolio/v2?user_id={two_users['b_id']}", headers=_auth(two_users["a_token"])
)
assert r.status_code == 200
assert "035420" in {h["stock_code"] for h in r.json()}
# 비관리자(B)가 ?user_id=A 로 조회 — 403
r = await client.get(
f"/api/v1/portfolio/v2?user_id={two_users['a_id']}", headers=_auth(two_users["b_token"])
)
assert r.status_code == 403
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code.in_(["000660", "035420"])))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# (g) POST /portfolio/v2/sell — 타 사용자 종목 404, 본인 종목은 ctx.user_id로 주문·close 호출
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_g_sell_v2_cross_user_404_and_own_stock_uses_ctx(client, monkeypatch, two_users):
from app.core.config import settings
from app.services.tenant import registry as registry_module
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
fake_kis_a = _FakeKis()
ctx_a = UserContext(
user_id=two_users["a_id"], username="a", mode="virtual", kis=fake_kis_a,
mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
)
async def _fake_resolve(current_user, db):
return ctx_a
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
close_calls = []
async def _fake_close(user_id, stock_code, strategy_name=None):
close_calls.append((user_id, stock_code, strategy_name))
monkeypatch.setattr(
"app.api.endpoints.portfolio_v2.enqueue_close_active_trade_v2", _fake_close
)
async with AsyncSessionLocal() as db:
db.add(Portfolio(
user_id=two_users["a_id"], stock_code="005380", stock_name="A보유",
quantity=10, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
db.add(Portfolio(
user_id=two_users["b_id"], stock_code="051910", stock_name="B보유",
quantity=10, average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
await db.commit()
try:
# B 종목을 A가 매도 시도 → 404
r = await client.post(
"/api/v1/portfolio/v2/sell",
headers=_auth(two_users["a_token"]),
json={"stock_code": "051910", "quantity": 1},
)
assert r.status_code == 404
# A 종목 매도 → 성공, fake kis 호출 인자에 ctx.user_id 반영 확인
r = await client.post(
"/api/v1/portfolio/v2/sell",
headers=_auth(two_users["a_token"]),
json={"stock_code": "005380", "quantity": 10},
)
assert r.status_code == 200, r.text
assert fake_kis_a.place_order_calls, "safe_place_order_v2가 ctx.kis(fake)를 통해 주문해야 한다"
call = fake_kis_a.place_order_calls[0]
assert call["stock_code"] == "005380" and call["order_type"] == "SELL"
assert close_calls and close_calls[0][0] == two_users["a_id"] and close_calls[0][1] == "005380"
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code.in_(["005380", "051910"])))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# (h) ensure_bootstrap_admin() — 빈 users, KIS env 이관, idempotent, NULL 백필
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_h_bootstrap_admin_seed_and_idempotent(monkeypatch):
from app.core.config import settings
# 이 테스트만을 위한 격리 — users 테이블을 비워야 하므로 세션 스코프 오염 방지를 위해
# 테스트 전용 사용자명으로 필터링된 범위만 조작 가능해야 하나, ensure_bootstrap_admin은
# "users 테이블 전체가 비어있는지"를 보므로 실제로 테이블을 비운다 — 임시 DB 한정.
async with AsyncSessionLocal() as db:
existing = (await db.execute(select_all_users())).scalars().all()
assert existing == [], (
"이 테스트는 users 테이블이 완전히 비어 있어야 한다 — 임시 격리 DB 전용 테스트. "
"다른 테스트와 병렬/순서 오염 시 실패할 수 있음 (세션 순차 실행 전제)."
)
monkeypatch.setattr(settings, "BOOTSTRAP_ADMIN_PASSWORD", "bootstrap-pw-123")
monkeypatch.setattr(settings, "BOOTSTRAP_ADMIN_USERNAME", f"bootadmin_{_SUFFIX}")
monkeypatch.setattr(settings, "CREDENTIALS_ENCRYPTION_KEY", "x" * 40)
monkeypatch.setattr(settings, "KOREA_INVESTMENT_APP_KEY", f"envappkey{_SUFFIX}")
monkeypatch.setattr(settings, "KOREA_INVESTMENT_APP_SECRET", "envsecret")
monkeypatch.setattr(settings, "KOREA_INVESTMENT_ACCOUNT_NUMBER", "1234567801")
monkeypatch.setattr(settings, "KIS_MODE", "virtual")
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", False)
try:
await tenant_bootstrap.ensure_bootstrap_admin()
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
admin = (
await db.execute(_select(User).where(User.username == f"bootadmin_{_SUFFIX}"))
).scalar_one_or_none()
assert admin is not None
assert admin.is_admin is True and admin.is_active is True
cred = (
await db.execute(_select(UserKisCredential).where(UserKisCredential.user_id == admin.id))
).scalar_one_or_none()
assert cred is not None
from app.core.crypto import decrypt
assert decrypt(cred.app_key_enc, cred.key_version) == f"envappkey{_SUFFIX}"
assert decrypt(cred.account_no_enc, cred.key_version) == "1234567801"
# NULL user_id 백필 확인용 레거시 행 삽입 후 재실행
async with AsyncSessionLocal() as db:
db.add(Portfolio(
user_id=None, stock_code="900001", stock_name="레거시", quantity=1,
average_price=100, current_price=100, market="KRX", strategy_type="swing",
))
await db.commit()
await tenant_bootstrap.ensure_bootstrap_admin() # 2번째 호출 — idempotent 여야 함
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select, func as _func
admin_count = (
await db.execute(
_select(_func.count()).select_from(User).where(User.username == f"bootadmin_{_SUFFIX}")
)
).scalar()
assert admin_count == 1, "재호출해도 관리자 계정이 중복 생성되지 않아야 한다"
cred_count = (
await db.execute(
_select(_func.count()).select_from(UserKisCredential).where(UserKisCredential.user_id == admin.id)
)
).scalar()
assert cred_count == 1, "재호출해도 KIS 자격증명이 중복 이관되지 않아야 한다"
legacy_row = (
await db.execute(_select(Portfolio).where(Portfolio.stock_code == "900001"))
).scalar_one_or_none()
assert legacy_row is not None and legacy_row.user_id == admin.id, (
"user_id IS NULL 이던 레거시 행이 owner로 백필되어야 한다"
)
finally:
async with AsyncSessionLocal() as db:
from sqlalchemy import select as _select
admin = (
await db.execute(_select(User).where(User.username == f"bootadmin_{_SUFFIX}"))
).scalar_one_or_none()
await db.execute(delete(Portfolio).where(Portfolio.stock_code == "900001"))
if admin is not None:
await db.execute(delete(UserKisCredential).where(UserKisCredential.user_id == admin.id))
await db.execute(delete(User).where(User.id == admin.id))
await db.commit()
def select_all_users():
from sqlalchemy import select as _select
return _select(User)
# ─────────────────────────────────────────────────────────────────────────────
# M1 회귀 방지 — CurrentUser.effective_user_id 와 UserContextRegistry.legacy().user_id 가
# 관리자 미존재 상태에서 모두 None 으로 일치해야 한다(수정 전에는 0 vs None 불일치 결함이었음).
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_m1_effective_user_id_matches_legacy_owner_id_when_no_admin():
"""관리자 0명 상태에서 `CurrentUser.effective_user_id`와
`UserContextRegistry.legacy().user_id`가 모두 `None`으로 일치해야 한다(M1).
수정 전에는 후자가 `users.id=0`(존재하지 않는 FK 위반 값)을 반환해
`enqueue_trade_history({"user_id": 0})` → FK 위반 → db_worker 재시도 후 폐기로
이어졌다.
"""
from app.core.tenant_auth import CurrentUser
async with AsyncSessionLocal() as db:
no_admin = (
await db.execute(select_all_users())
).scalars().all()
# 관리자 없는 상태를 흉내내기 위해 admin 여부만 확인 (실제 삭제는 하지 않음 — 다른 테스트와 공유 DB)
has_admin = any(u.is_admin for u in no_admin)
current_user = CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True, _legacy_owner_id=None)
assert current_user.effective_user_id is None
if not has_admin:
# legacy 캐시가 이전 테스트에서 owner_id로 확정(캐시)돼 있을 수 있으므로 리셋
user_context_registry._legacy_ctx = None
user_context_registry._legacy_checked_at = 0.0
user_context_registry._legacy_owner_confirmed = False
legacy_ctx = await user_context_registry.legacy(None)
assert legacy_ctx.user_id is None
assert current_user.effective_user_id == legacy_ctx.user_id
# ─────────────────────────────────────────────────────────────────────────────
# H2 — 비활성(회수) 세션 사용자는 legacy owner 로 폴백하지 않고 401
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_h2_inactive_session_user_returns_401_not_legacy_fallback(client):
async with AsyncSessionLocal() as db:
u = User(username=f"mt_revoked_{_SUFFIX}", password_hash="x", is_active=False, is_admin=False)
db.add(u)
await db.commit()
await db.refresh(u)
token, _ = create_session_token(u.username)
try:
r = await client.get("/api/v1/account/credentials", headers=_auth(token))
assert r.status_code == 401, r.text
r = await client.get("/api/v1/portfolio/v2", headers=_auth(token))
assert r.status_code == 401, r.text
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(User).where(User.id == u.id))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# H3 — 자격증명 PUT/DELETE/PATCH 후 registry 캐시가 무효화되어야 함
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_h3_credential_endpoints_invalidate_registry_cache(client, two_users):
user_id = two_users["a_id"]
headers = _auth(two_users["a_token"])
body = {"mode": "virtual", "app_key": f"h3key{_SUFFIX}", "app_secret": "s", "account_no": "1234567801"}
r = await client.put("/api/v1/account/credentials", headers=headers, json=body)
assert r.status_code == 200, r.text
dummy_ctx = UserContext(
user_id=user_id, username="stale", mode="virtual", kis=_FakeKis(),
mdd=None, loss_tracker=None, auto_trade_enabled=True, is_legacy=False,
)
# PUT은 이미 위에서 한 번 invalidate를 호출했으므로, 이후 더미를 주입해 재확인
user_context_registry._contexts[user_id] = dummy_ctx
assert user_context_registry._contexts.get(user_id) is dummy_ctx
r = await client.patch("/api/v1/account/auto-trade", headers=headers, json={"enabled": False})
assert r.status_code == 200, r.text
assert user_id not in user_context_registry._contexts, (
"PATCH /auto-trade 이후 캐시된 UserContext가 무효화되어야 한다(H3)"
)
user_context_registry._contexts[user_id] = dummy_ctx
r = await client.delete("/api/v1/account/credentials", headers=headers)
assert r.status_code == 204, r.text
assert user_id not in user_context_registry._contexts, (
"DELETE /credentials 이후 캐시된 UserContext가 무효화되어야 한다(H3)"
)
# ─────────────────────────────────────────────────────────────────────────────
# M1 — owner 없는 API 키(legacy ctx user_id=None) 매수 시 db_worker payload에 user_id 키가
# 없어야 한다(0을 넣어 FK 위반을 내던 구 결함 방지, 기존 NULL 행과 정합).
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_m1_legacy_ctx_no_owner_buy_payload_omits_user_id(client, monkeypatch):
from app.core.config import settings
from app.services.tenant import registry as registry_module
monkeypatch.setattr(settings, "MAX_HOLDINGS_COUNT", 10_000) # 공유 DB에 쌓인 NULL 행과 무관하게
fake_kis = _FakeKis()
ctx = UserContext(
user_id=None, username="legacy", mode="virtual", kis=fake_kis,
mdd=_FakeMddAlwaysOpen(), loss_tracker=_FakeLossTrackerNeverBanned(),
auto_trade_enabled=True, is_legacy=True,
)
async def _fake_resolve(current_user, db):
return ctx
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
captured_history: dict = {}
captured_portfolio: dict = {}
async def _fake_enqueue_trade_history(data):
captured_history.update(data)
async def _fake_enqueue_portfolio_upsert(data):
captured_portfolio.update(data)
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_trade_history", _fake_enqueue_trade_history)
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_portfolio_upsert", _fake_enqueue_portfolio_upsert)
stock_code = f"6{_SUFFIX[-5:]}"
try:
r = await client.post(
"/api/v1/portfolio/v2/buy",
headers=_auth(_get_api_key()),
json={"stock_code": stock_code, "quantity": 1},
)
assert r.status_code == 200, r.text
assert fake_kis.place_order_calls, "safe_place_order_v2가 ctx.kis(fake)를 통해 주문해야 한다"
assert "user_id" not in captured_history, "legacy ctx(owner 없음)는 trade_history payload에 user_id를 넣지 않아야 한다"
assert "user_id" not in captured_portfolio, "legacy ctx(owner 없음)는 portfolio upsert payload에 user_id를 넣지 않아야 한다"
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
await db.execute(delete(TradeHistory).where(TradeHistory.stock_code == stock_code))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# M3 — legacy ctx의 전량 매도는 enqueue_close_active_trade_v2가 아니라 기존
# enqueue_close_active_trade(user_id 없음)를 호출해야 한다(ActiveTrade.user_id==owner
# 필터로 NULL 행을 못 찾아 유령 포지션이 남는 결함 방지).
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_m3_legacy_sell_full_exit_uses_v1_close_not_v2(client, monkeypatch):
from app.services.tenant import registry as registry_module
fake_kis = _FakeKis()
ctx = UserContext(
user_id=None, username="legacy", mode="virtual", kis=fake_kis,
mdd=_FakeMddAlwaysOpen(), loss_tracker=_FakeLossTrackerNeverBanned(),
auto_trade_enabled=True, is_legacy=True,
)
async def _fake_resolve(current_user, db):
return ctx
monkeypatch.setattr(registry_module.user_context_registry, "resolve", _fake_resolve)
v1_calls = []
v2_calls = []
async def _fake_close_v1(stock_code, strategy_name=None):
v1_calls.append((stock_code, strategy_name))
async def _fake_close_v2(user_id, stock_code, strategy_name=None):
v2_calls.append((user_id, stock_code, strategy_name))
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_close_active_trade", _fake_close_v1)
monkeypatch.setattr("app.api.endpoints.portfolio_v2.enqueue_close_active_trade_v2", _fake_close_v2)
stock_code = f"5{_SUFFIX[-5:]}"
async with AsyncSessionLocal() as db:
db.add(Portfolio(
user_id=None, stock_code=stock_code, stock_name="레거시보유", quantity=10,
average_price=1000, current_price=1000, profit_loss=0, profit_loss_rate=0,
market="KRX", strategy_type="manual",
))
await db.commit()
try:
r = await client.post(
"/api/v1/portfolio/v2/sell",
headers=_auth(_get_api_key()),
json={"stock_code": stock_code, "quantity": 10},
)
assert r.status_code == 200, r.text
assert v1_calls and v1_calls[0][0] == stock_code
assert not v2_calls, "legacy ctx는 enqueue_close_active_trade_v2가 아니라 v1(close_active_trade)을 호출해야 한다"
finally:
async with AsyncSessionLocal() as db:
await db.execute(delete(Portfolio).where(Portfolio.stock_code == stock_code))
await db.commit()
# ─────────────────────────────────────────────────────────────────────────────
# M5 — MULTI_TENANT_ENABLED=True 에서 legacy owner가 아닌 세션 사용자는 legacy
# `/portfolio` 라우터 접근이 403이어야 한다(`deny_non_owner_session_when_mt`).
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_m5_legacy_portfolio_denied_for_non_owner_session_when_mt(client, monkeypatch, two_users):
from app.core.config import settings
monkeypatch.setattr(settings, "MULTI_TENANT_ENABLED", True)
# two_users["b"]는 non-admin(legacy owner일 수 없음)
r = await client.get("/api/v1/portfolio", headers=_auth(two_users["b_token"]))
assert r.status_code == 403, r.text
r = await client.get("/api/v1/portfolio/v2", headers=_auth(two_users["b_token"]))
assert r.status_code != 403, "/portfolio/v2 는 deny_non_owner_session_when_mt 대상이 아니다"