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:
0
backend/tests/integration/__init__.py
Normal file
0
backend/tests/integration/__init__.py
Normal file
91
backend/tests/integration/conftest.py
Normal file
91
backend/tests/integration/conftest.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""통합 테스트(D그룹) 공용 fixture — 격리된 임시 DB 전용.
|
||||
|
||||
H4: `test_multi_tenant.py`와 `test_kis_credentials.py`가 각자 정의하던 이벤트 루프/
|
||||
lifespan/스키마 생성/DB 가드/HTTP 클라이언트 fixture를 이 파일로 통합한다. 두 파일
|
||||
모두 이 conftest를 통해 동일한 가드를 거치므로, `tests/integration` 디렉터리를
|
||||
통째로 실행해도(예: `pytest tests/integration -m integration`) users 테이블 등
|
||||
스키마가 없어 실패하는 일이 없다.
|
||||
|
||||
가드: `settings.DATABASE_URL`의 DB 이름이 `_test`로 끝나지 않으면 즉시
|
||||
`pytest.exit()` — 라이브 DB(예: 포트 15433의 `autostock_db`)를 실수로 겨냥한
|
||||
실행을 세션 시작 단계에서 차단한다. `os.environ["DATABASE_URL"]`만 보던 이전 가드는
|
||||
env 미설정 시 `settings.DATABASE_URL` 기본값(라이브 가능성)으로 조용히 새는 결함이
|
||||
있었다(구 검수 H4).
|
||||
|
||||
실행 예시 (임시 Postgres 컨테이너 안에서):
|
||||
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 -m integration -v"
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
|
||||
# ── 임시 DB 강제 확인 — DB 이름이 `_test`로 끝나지 않으면 세션 시작 자체를 중단 ──
|
||||
_db_url = settings.DATABASE_URL or ""
|
||||
_db_name = _db_url.rsplit("/", 1)[-1].split("?", 1)[0]
|
||||
if not _db_name.endswith("_test"):
|
||||
pytest.exit(
|
||||
f"settings.DATABASE_URL의 DB 이름이 '_test'로 끝나지 않습니다(현재: {_db_name!r}). "
|
||||
"통합 테스트는 반드시 임시 DB(예: autostock_test)를 대상으로 실행해야 합니다.",
|
||||
returncode=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""세션 전체가 동일 이벤트 루프를 공유하도록 강제 (asyncpg 풀 재사용).
|
||||
|
||||
기본(함수 스코프) 이벤트 루프를 쓰면 테스트마다 새 루프가 생성되는데, 앱 임포트 시
|
||||
한 번 만들어진 asyncpg 커넥션 풀(`AsyncSessionLocal`)의 유휴 커넥션이 이전 루프에
|
||||
묶여 있어 다음 테스트에서 "Event loop is closed" 오류가 난다.
|
||||
"""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_lifespan(app):
|
||||
"""실거래 스케줄러/db_writer_worker/부트스트랩 자동 기동 방지 — 필요한 것만 테스트가 직접 기동."""
|
||||
yield
|
||||
|
||||
|
||||
from app.core.database import Base, engine # noqa: E402
|
||||
from app.main import app as fastapi_app # noqa: E402
|
||||
|
||||
fastapi_app.router.lifespan_context = _noop_lifespan
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def _create_schema():
|
||||
"""임시 DB이므로 alembic 대신 `Base.metadata.create_all`로 스키마를 통째로 생성."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=fastapi_app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
def _auth(token: str) -> dict:
|
||||
return {"X-API-Key": token}
|
||||
202
backend/tests/integration/test_kis_credentials.py
Normal file
202
backend/tests/integration/test_kis_credentials.py
Normal 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
|
||||
964
backend/tests/integration/test_multi_tenant.py
Normal file
964
backend/tests/integration/test_multi_tenant.py
Normal file
@@ -0,0 +1,964 @@
|
||||
"""멀티테넌트(사용자별 매매) 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 대상이 아니다"
|
||||
Reference in New Issue
Block a user