"""멀티테넌트(사용자별 매매) 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 대상이 아니다"