Files
auto-stock/backend/tests/test_infra_security_round2_20261002.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

1694 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""검증 루프 2회차 (2026-10-02) — 인프라·데이터·보안 회귀 테스트.
H-1 공개 GET 계좌정보 노출 / H-2 세션 is_active·관리자 게이트 / H-3 토큰 락 안 sleep /
H-4 토큰 무효 응답 / H-5 db_worker cancel 유실 / M-2 클라이언트 IP / M-5 키 분리·점검 /
M-6 토큰 로그 마스킹 / M-7 created_at / L-4 KRX 휴장.
원칙: 외부 호출 0 (KIS 는 conftest 가 클래스 단위로 차단, 여기서는 HTTP 클라이언트·DB 세션을
가짜로 주입), 목은 예외 발생형, 기대값은 settings 참조.
"""
import asyncio
import logging
import time
import types
from datetime import date, datetime, timedelta
import pytest
from fastapi import HTTPException
from app.core.config import settings
from app.services import korea_investment_api as kis_mod
# conftest 가 테스트마다 클래스 메서드를 차단 스텁으로 덮는다 → 수집 시점의 원본을 보관
_ORIG_GET_ACCESS_TOKEN = kis_mod.KoreaInvestmentAPI.__dict__["_get_access_token"]
_ORIG_REQUEST_NEW_TOKEN = kis_mod.KoreaInvestmentAPI.__dict__["_request_new_token"]
_ORIG_PLACE_ORDER = kis_mod.KoreaInvestmentAPI.__dict__["place_order"]
_ORIG_CANCEL_ORDER = kis_mod.KoreaInvestmentAPI.__dict__["cancel_order"]
_ORIG_CANCEL_ORDER_V2 = kis_mod.KoreaInvestmentAPI.__dict__["cancel_order_v2"]
# ─────────────────────────────────────────────────────────────────────────────
# H-5: db_worker cancel 시 유실
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def fresh_queue(monkeypatch):
from app.services import db_worker as w
monkeypatch.setattr(w, "_db_task_queue", None)
return w
@pytest.mark.asyncio
async def test_cancel_during_retry_backoff_preserves_backoff_task_and_queue(fresh_queue, monkeypatch):
"""재시도 백오프 sleep 중 cancel → 백오프 태스크와 큐 잔여가 모두 drain(→DLQ) 된다.
수정 전: CancelledError 가 except Exception 핸들러 안의 sleep 에서 나와 drain 을 건너뛰고
3건 전부 유실(dlq=[]).
"""
w = fresh_queue
dlq: list[tuple[str, str]] = []
class _FailSession:
async def __aenter__(self):
raise RuntimeError("db down")
async def __aexit__(self, *a):
return False
async def _fake_dlq(task, err):
dlq.append((task["data"]["stock_code"], err))
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _FailSession())
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
q = w.get_db_task_queue()
worker = asyncio.create_task(w.db_writer_worker())
await q.put({"type": "close_active_trade", "data": {"stock_code": "A"}})
await q.put({"type": "upsert_active_trade", "data": {"stock_code": "B"}})
await q.put({"type": "insert_trade_history", "data": {"stock_code": "C"}})
await asyncio.sleep(0.2) # A 가 1초 백오프 sleep 중
worker.cancel()
await worker # drain 후 정상 종료 (CancelledError 를 밖으로 내지 않는다)
codes = [c for c, _ in dlq]
assert codes[0] == "A", "백오프 태스크가 drain 목록 맨 앞이어야 한다"
assert sorted(codes) == ["A", "B", "C"], dlq
assert q.qsize() == 0
@pytest.mark.asyncio
async def test_cancel_during_inflight_commit_preserves_task_in_dlq(fresh_queue, monkeypatch):
"""처리 중(커밋 대기) cancel → 비멱등 INSERT 는 커밋 여부 불명이라 DLQ, 큐 잔여도 처리."""
w = fresh_queue
dlq: list[tuple[str, str]] = []
class _SlowSession:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def add(self, obj):
pass
async def commit(self):
await asyncio.sleep(5)
class _TH:
def __init__(self, **kw):
self.kw = kw
async def _fake_dlq(task, err):
dlq.append((task["data"]["stock_code"], err))
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _SlowSession())
monkeypatch.setattr(w, "TradeHistory", _TH)
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
q = w.get_db_task_queue()
worker = asyncio.create_task(w.db_writer_worker())
await q.put({"type": "insert_trade_history", "data": {"stock_code": "INFLIGHT"}})
await q.put({"type": "update_trade_history", "data": {"id": 1, "stock_code": "QUEUED"}})
await asyncio.sleep(0.1)
worker.cancel()
await worker
by_code = dict(dlq)
assert "INFLIGHT" in by_code, "처리 중이던 태스크가 DLQ 에 보존돼야 한다"
assert "commit_unknown" in by_code["INFLIGHT"]
assert "QUEUED" in by_code
@pytest.mark.asyncio
async def test_cancel_while_idle_returns_normally(fresh_queue):
w = fresh_queue
worker = asyncio.create_task(w.db_writer_worker())
await asyncio.sleep(0.05)
worker.cancel()
await worker
assert worker.done() and not worker.cancelled()
def test_shutdown_cancels_db_worker_last():
"""[H-5(d)] lifespan 종료 순서 — db_worker 가 생산자보다 뒤."""
import inspect
from app import main
src = inspect.getsource(main.lifespan)
block = src[src.index("# 태스크 정리"):]
names = [n for n in (
"swing_trade_task", "day_trade_task", "us_trade_task", "news_collect_task",
"adaptive_strategy_task", "ai_strategy_task", "db_worker_task",
)]
pos = {n: block.index(f'"{n}"') for n in names}
assert pos["db_worker_task"] == max(pos.values())
def _compose_stop_grace() -> int:
from pathlib import Path
import yaml # PyYAML (uvicorn[standard] 의존성)
compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text())
grace = compose["services"]["backend"].get("stop_grace_period", "10s")
assert grace.endswith("s")
return int(grace[:-1])
def test_compose_backend_stop_grace_exceeds_drain_deadline():
from app.services.db_worker import DRAIN_DEADLINE_SECONDS
assert _compose_stop_grace() > DRAIN_DEADLINE_SECONDS
def test_shutdown_budget_fits_stop_grace_and_dockerfile_matches():
"""[M2] uvicorn graceful + 백그라운드 대기 + drain + 여유(5s) ≤ stop_grace_period, Dockerfile CMD 값 일치."""
import json as _json
import re
from pathlib import Path
from app.services.db_worker import DRAIN_DEADLINE_SECONDS
dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text()
cmd = [l for l in dockerfile.splitlines() if l.startswith("CMD ")][-1]
args = _json.loads(cmd[4:])
# [L-3] CMD 가 설정 키와 같은 이름의 환경변수를 읽고, 기본값이 설정 기본값과 같아야 한다
assert args[:2] == ["sh", "-c"] and args[2].startswith("exec uvicorn ")
m = re.search(r"--timeout-graceful-shutdown \$\{UVICORN_GRACEFUL_SHUTDOWN_SECONDS:-(\d+)\}", args[2])
assert m, args[2]
assert int(m.group(1)) == settings.UVICORN_GRACEFUL_SHUTDOWN_SECONDS
assert settings.CONTAINER_STOP_GRACE_SECONDS == _compose_stop_grace()
budget = (settings.UVICORN_GRACEFUL_SHUTDOWN_SECONDS + settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS
+ DRAIN_DEADLINE_SECONDS)
assert budget + 5 <= _compose_stop_grace(), (budget, _compose_stop_grace())
# ─────────────────────────────────────────────────────────────────────────────
# M-7: created_at 은 큐 투입 시각
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_enqueue_trade_history_stamps_created_at(fresh_queue):
w = fresh_queue
payload = {"stock_code": "005930", "order_type": "BUY"}
before = w.now_kst()
await w.enqueue_trade_history(payload)
task = w.get_db_task_queue().get_nowait()
assert "created_at" not in payload, "호출자 dict 를 변경하면 안 된다"
assert task["data"]["created_at"] >= before
fixed = datetime(2026, 9, 1, 10, 0, 0)
await w.enqueue_trade_history({"stock_code": "1", "created_at": fixed})
assert w.get_db_task_queue().get_nowait()["data"]["created_at"] == fixed
# ─────────────────────────────────────────────────────────────────────────────
# H-3 / H-4: KIS 토큰
# ─────────────────────────────────────────────────────────────────────────────
def _bare_api(tmp_path):
api = kis_mod.KoreaInvestmentAPI.__new__(kis_mod.KoreaInvestmentAPI)
api.access_token = None
api.token_expires_at = None
api._token_hard_expires_at = None
api._token_lock = asyncio.Lock()
api.TOKEN_CACHE_FILE = tmp_path / "kis_token_cache.json"
api._get_access_token = types.MethodType(_ORIG_GET_ACCESS_TOKEN, api)
return api
@pytest.mark.asyncio
async def test_token_reissue_window_fails_fast_without_sleep(tmp_path, monkeypatch):
"""재현(검증): 직전 발급 실패 + 대기자 5 → 이전 구현은 락 안 70초×5 = 355초 직렬 sleep."""
api = _bare_api(tmp_path)
async def _no_sleep(*a, **k):
raise AssertionError("토큰 경로에서 sleep 금지 (H-3)")
monkeypatch.setattr(kis_mod.asyncio, "sleep", _no_sleep)
async def _must_not_issue():
raise AssertionError("재발급 최소 간격 안에서는 발급을 시도하면 안 된다")
api._request_new_token = _must_not_issue
api._last_token_request_time = datetime.now()
started = time.monotonic()
results = await asyncio.gather(*[api._get_access_token() for _ in range(5)], return_exceptions=True)
assert all(isinstance(r, kis_mod.KISTokenUnavailableError) for r in results), results
assert time.monotonic() - started < 1.0
assert 0 < results[0].retry_after_sec <= settings.KIS_TOKEN_REISSUE_MIN_INTERVAL_SEC
@pytest.mark.asyncio
async def test_token_single_flight_one_issue_for_many_callers(tmp_path):
api = _bare_api(tmp_path)
calls = {"n": 0}
async def _issue():
calls["n"] += 1
await asyncio.sleep(0.05)
api.access_token = "NEW"
api.token_expires_at = datetime.now() + timedelta(hours=23)
return "NEW"
api._request_new_token = _issue
tokens = await asyncio.gather(*[api._get_access_token() for _ in range(5)])
assert tokens == ["NEW"] * 5
assert calls["n"] == 1
@pytest.mark.asyncio
async def test_make_request_acquires_token_outside_semaphore(tmp_path):
"""[H-3] 토큰을 못 얻으면 세마포어에 들어가지 않는다(다른 호출의 슬롯을 점유하지 않음)."""
api = _bare_api(tmp_path)
class _Breaker:
recovery_timeout = 120
def can_request(self):
return True
class _Sem:
entered = False
async def __aenter__(self):
_Sem.entered = True
raise AssertionError("토큰 없이 세마포어 진입")
async def __aexit__(self, *a):
return False
api.circuit_breaker = _Breaker()
api.semaphore = _Sem()
async def _unavailable():
raise kis_mod.KISTokenUnavailableError("wait", retry_after_sec=30)
api._get_access_token = _unavailable
with pytest.raises(kis_mod.KISTokenUnavailableError):
await api._make_request("GET", "/uapi/x", "TR")
assert _Sem.entered is False
class _Resp:
def __init__(self, status, payload):
self.status_code = status
self._payload = payload
self.headers = {}
self.text = str(payload)
def json(self):
return self._payload
class _Client:
def __init__(self, responses):
self.responses = list(responses)
self.auth_headers: list[str] = []
async def request(self, method, url, headers=None, timeout=None, **kw):
self.auth_headers.append(headers["authorization"])
return self.responses.pop(0)
def _api_for_request(tmp_path, client, tokens):
api = _bare_api(tmp_path)
api.base_url = "https://example.invalid"
api.app_key = "k"
api.app_secret = "s"
api.last_request_time = None
api._throttle_lock = asyncio.Lock()
api.min_request_interval = 0
api.max_retries = 3
api.retry_delay = 0
api.retry_backoff = 1
api.timeout = 5
api.monitor = kis_mod.APICallMonitor(window_size=10)
api.circuit_breaker = kis_mod.CircuitBreaker(failure_threshold=7, recovery_timeout=120)
it = iter(tokens)
async def _tok():
if api.access_token is None:
api.access_token = next(it)
api.token_expires_at = datetime.now() + timedelta(hours=1)
return api.access_token
api._get_access_token = _tok
async def _client():
return client
api._get_http_client = _client
return api
class _TrackingSem:
"""세마포어 대체 — 진입 중 여부를 기록해 '세마포어 안에서 토큰 발급' 을 잡아낸다."""
def __init__(self):
self.held = False
async def __aenter__(self):
self.held = True
async def __aexit__(self, *a):
self.held = False
return False
def _with_sem(api):
api.semaphore = _TrackingSem()
issued_under_sem = []
inner = api._get_access_token
async def _tok():
if api.access_token is None:
issued_under_sem.append(api.semaphore.held)
return await inner()
api._get_access_token = _tok
return issued_under_sem
@pytest.mark.asyncio
async def test_invalid_token_response_clears_cache_and_retries_once(tmp_path):
client = _Client([
_Resp(500, {"rt_cd": "1", "msg_cd": "EGW00123", "msg1": "기간이 만료된 token 입니다."}),
_Resp(200, {"rt_cd": "0", "output": {"ok": 1}}),
])
api = _api_for_request(tmp_path, client, ["OLD", "NEW"])
issued_under_sem = _with_sem(api)
api.TOKEN_CACHE_FILE.write_text('{"access_token": "OLD"}')
data, _ = await api._make_request("GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "TR")
assert data["rt_cd"] == "0"
assert client.auth_headers == ["Bearer OLD", "Bearer NEW"]
assert not api.TOKEN_CACHE_FILE.exists(), "무효 토큰 파일 캐시가 삭제돼야 한다(재기동 재사용 방지)"
assert issued_under_sem == [False, False], "토큰 발급은 세마포어 밖에서 (H-3 취지)"
assert api.circuit_breaker.failure_count == 0, "토큰 재발급 신호를 KIS 장애로 세면 안 된다"
@pytest.mark.asyncio
async def test_invalid_token_retry_is_bounded(tmp_path):
client = _Client([
_Resp(200, {"rt_cd": "1", "msg_cd": "EGW00121"}),
_Resp(200, {"rt_cd": "1", "msg_cd": "EGW00121"}),
])
api = _api_for_request(tmp_path, client, ["T1", "T2", "T3"])
_with_sem(api)
data, _ = await api._make_request("GET", "/uapi/q", "TR")
assert data["msg_cd"] == "EGW00121"
assert len(client.auth_headers) == 2, "재발급·재전송은 요청당 1회"
assert api.access_token is None
# 다음 요청은 다시 1회 재전송 기회를 가진다 (contextvar 가 새지 않음)
assert kis_mod._TOKEN_REFRESH_RETRIED.get() is False
@pytest.mark.asyncio
async def test_invalid_token_on_order_endpoint_is_not_resent(tmp_path):
client = _Client([_Resp(200, {"rt_cd": "1", "msg_cd": "EGW00123"})])
api = _api_for_request(tmp_path, client, ["OLD", "NEW"])
_with_sem(api)
data, _ = await api._make_request(
"POST", "/uapi/domestic-stock/v1/trading/order-cash", "TR"
)
assert data["msg_cd"] == "EGW00123"
assert len(client.auth_headers) == 1, "주문은 재전송하지 않는다"
assert api.access_token is None
@pytest.mark.asyncio
async def test_place_order_presend_token_failure_is_tagged(tmp_path):
"""전송 전 토큰 부재 실패는 msg_cd=TOKEN_UNAVAILABLE — 호출부가 '미전송'을 구분할 수 있다."""
api = _bare_api(tmp_path)
api.base_url = "https://openapivts.example.invalid"
api.app_key = "k"
api.app_secret = "s"
api.account_number = "1234567801"
# conftest 가 place_order 를 차단하므로 원본을 직접 바인딩한다 (수집 시점 원본)
place_order = _ORIG_PLACE_ORDER
async def _unavailable(*a, **k):
raise kis_mod.KISTokenUnavailableError("wait", retry_after_sec=42)
api._make_request = _unavailable
res = await types.MethodType(place_order, api)("005930", "BUY", 1, 70000)
assert res["rt_cd"] == "-1"
assert res["msg_cd"] == kis_mod.KIS_PRESEND_TOKEN_UNAVAILABLE == "TOKEN_UNAVAILABLE"
assert res["odno"] is None and res["retry_after_sec"] == 42
def test_grace_extension_bounded_by_hard_expiry(tmp_path):
api = _bare_api(tmp_path)
api.access_token = "T"
# 실제 만료가 이미 지났으면 연장 금지 (이전: 무조건 +1h)
api._token_hard_expires_at = datetime.now() - timedelta(minutes=1)
assert api._grace_extend_existing_token() is None
# 실제 만료 전이면 짧게만 연장
api._token_hard_expires_at = datetime.now() + timedelta(minutes=40)
assert api._grace_extend_existing_token() == "T"
assert api.token_expires_at <= datetime.now() + timedelta(minutes=settings.KIS_TOKEN_GRACE_EXTEND_MINUTES, seconds=1)
assert api.token_expires_at < api._token_hard_expires_at
@pytest.mark.asyncio
async def test_request_new_token_failure_without_hard_expiry_raises(tmp_path, monkeypatch):
api = _bare_api(tmp_path)
api.base_url = "https://example.invalid"
api.app_key = "k"
api.app_secret = "s"
api.access_token = "EXPIRED"
api._token_hard_expires_at = datetime.now() - timedelta(minutes=10)
api._request_new_token = types.MethodType(_ORIG_REQUEST_NEW_TOKEN, api)
class _FailingClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, *a, **k):
return _Resp(403, {"error_code": "EGW00133"})
monkeypatch.setattr(kis_mod.httpx, "AsyncClient", lambda *a, **k: _FailingClient())
with pytest.raises(Exception, match="Failed to get access token"):
await api._request_new_token()
# ─────────────────────────────────────────────────────────────────────────────
# H-2 / M-5: 세션 검증
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def security(monkeypatch):
from app.core import security as sec
sec.invalidate_session_user_cache()
yield sec
sec.invalidate_session_user_cache()
def _patch_flags(monkeypatch, sec, mapping, calls=None):
async def _fetch(username):
if calls is not None:
calls.append(username)
if username not in mapping:
raise RuntimeError("db down")
return mapping[username]
monkeypatch.setattr(sec, "_fetch_session_user_flags_impl", _fetch)
@pytest.mark.asyncio
async def test_revoked_user_session_rejected(security, monkeypatch):
sec = security
token, _ = sec.create_session_token("bob")
flags = {"bob": sec.SessionUserFlags(exists=True, is_active=True, is_admin=False)}
_patch_flags(monkeypatch, sec, flags)
assert await sec.is_valid_credential_v2(token) is True
assert await sec.require_api_key(token) == token
flags["bob"] = sec.SessionUserFlags(exists=True, is_active=False, is_admin=False)
sec.invalidate_session_user_cache("bob") # revoke_user 가 호출하는 것과 동일
assert await sec.is_valid_credential_v2(token) is False
with pytest.raises(HTTPException) as ei:
await sec.require_api_key(token)
assert ei.value.status_code == 401
# 레거시 동기 판정은 여전히 서명만 본다 — 인증 경로가 이것을 쓰면 안 되는 이유
assert sec.is_valid_credential(token) is True
@pytest.mark.asyncio
async def test_session_flags_cached_with_ttl(security, monkeypatch):
sec = security
calls: list[str] = []
_patch_flags(monkeypatch, sec, {"bob": sec.SessionUserFlags(True, True, False)}, calls)
await sec.get_session_user_flags("bob")
await sec.get_session_user_flags("bob")
assert calls == ["bob"]
monkeypatch.setattr(settings, "SESSION_ACTIVE_CACHE_SECONDS", 0)
await sec.get_session_user_flags("bob")
assert calls == ["bob", "bob"]
@pytest.mark.asyncio
async def test_session_lookup_failure_503_without_cache_stale_with_cache(security, monkeypatch):
sec = security
_patch_flags(monkeypatch, sec, {})
with pytest.raises(HTTPException) as ei:
await sec.get_session_user_flags("carol")
assert ei.value.status_code == 503, "DB 일시 장애로 401(로그아웃)을 내면 안 된다"
sec._session_user_cache["carol"] = (sec.SessionUserFlags(True, True, False), time.monotonic() - 9999)
flags = await sec.get_session_user_flags("carol")
assert flags.is_active is True
@pytest.mark.asyncio
async def test_api_key_is_admin_without_db(security, monkeypatch):
sec = security
_patch_flags(monkeypatch, sec, {}) # DB 를 부르면 실패
info = await sec.resolve_credential_v2(sec._get_api_key())
assert info is not None and info.kind == "api_key" and info.is_admin
def test_session_signing_key_separated_from_api_key(security, monkeypatch):
"""[M-5] API_KEY 미설정이면 API 키 = SECRET_KEY. 그 키로 직접 서명한 토큰은 거부돼야 한다."""
from jose import jwt
sec = security
monkeypatch.setattr(settings, "API_KEY", "")
forged = jwt.encode(
{"sub": "admin", "aud": sec._SESSION_AUD, "exp": datetime.utcnow() + timedelta(hours=1)},
sec._get_api_key(), algorithm=sec._SESSION_ALG,
)
assert sec.verify_session_token(forged) is None
good, _ = sec.create_session_token("admin")
assert sec.verify_session_token(good) == "admin"
# API_KEY 가 별도로 있으면 배포 전 발급분(SECRET_KEY 서명)을 하위호환으로 받는다
monkeypatch.setattr(settings, "API_KEY", "x" * 80)
legacy = jwt.encode(
{"sub": "admin", "aud": sec._SESSION_AUD, "exp": datetime.utcnow() + timedelta(hours=1)},
settings.SECRET_KEY, algorithm=sec._SESSION_ALG,
)
assert sec.verify_session_token(legacy) == "admin"
def test_security_key_issues(monkeypatch):
from app.core.security import security_key_issues
monkeypatch.setattr(settings, "SECRET_KEY", "change-this-secret-key")
monkeypatch.setattr(settings, "API_KEY", "")
issues = security_key_issues()
assert any("SECRET_KEY" in i for i in issues) and any("API_KEY" in i for i in issues)
strong = "a" * settings.MIN_SECRET_KEY_LENGTH
monkeypatch.setattr(settings, "SECRET_KEY", strong)
monkeypatch.setattr(settings, "API_KEY", "b" * settings.MIN_SECRET_KEY_LENGTH)
assert security_key_issues() == []
monkeypatch.setattr(settings, "SECRET_KEY", strong[:-1])
assert security_key_issues()
# ─────────────────────────────────────────────────────────────────────────────
# H-1 / H-2: 라우트 인증 정책 (앱 라우트 테이블 기준 — 핸들러 실행 없음)
# ─────────────────────────────────────────────────────────────────────────────
def _route_dep_calls():
from fastapi.routing import APIRoute
from app.main import app
out = {}
for r in app.routes:
if not isinstance(r, APIRoute):
continue
def _walk(dep, acc):
for d in dep.dependencies:
if d.call is not None:
acc.add(getattr(d.call, "__name__", str(d.call)))
_walk(d, acc)
acc: set = set()
_walk(r.dependant, acc)
for m in r.methods:
out[(m, r.path)] = acc
return out
_MUST_REQUIRE_KEY = [
("GET", "/api/v1/journals"),
("GET", "/api/v1/journals/stats"),
("GET", "/api/v1/analysis/performance"),
("GET", "/api/v1/analysis/insights"),
("GET", "/api/v1/market-intelligence/dashboard"),
("GET", "/api/v1/market-data/price/{stock_code}"),
("GET", "/api/v1/market-data/prices"),
("GET", "/api/v1/market-data/chart/{stock_code}"),
("GET", "/api/v1/market-data/orderbook/{stock_code}"),
("GET", "/api/v1/market-data/ai-analysis/{stock_code}"),
("GET", "/api/v1/market-data/overseas/price/{ticker}"),
("GET", "/api/v1/ml-ensemble/predict/{stock_code}"),
]
def test_sensitive_gets_require_api_key():
deps = _route_dep_calls()
for key in _MUST_REQUIRE_KEY:
assert key in deps, f"라우트 없음(경로 변경?): {key}"
assert "require_api_key" in deps[key], f"인증 없이 열려 있음: {key}"
def test_public_market_indices_stays_public():
deps = _route_dep_calls()
d = deps[("GET", "/api/v1/market/indices")]
assert "require_api_key" not in d
def test_admin_only_routes_exist_and_are_gated():
"""ADMIN_ONLY_ROUTES 의 오타는 '보호가 조용히 빠짐'이 되므로 실제 라우트와 대조한다."""
from app.core.security import ADMIN_ONLY_ROUTES, is_admin_only_route
deps = _route_dep_calls()
for method, path in ADMIN_ONLY_ROUTES:
full = "/api/v1" + path
assert (method, full) in deps, f"ADMIN_ONLY_ROUTES 항목에 해당 라우트 없음: {method} {full}"
assert "require_admin_for_listed_routes" in deps[(method, full)]
assert is_admin_only_route(method, full)
assert is_admin_only_route("GET", "/api/v1/debug/debug/autotrade-status")
assert not is_admin_only_route("GET", "/api/v1/strategy-config")
assert not is_admin_only_route("POST", "/api/v1/daytrading/watchlist")
@pytest.mark.asyncio
async def test_unauthenticated_sensitive_get_is_401_and_non_admin_write_is_403(security, monkeypatch):
import httpx
from httpx import ASGITransport
from app.main import app
sec = security
_patch_flags(monkeypatch, sec, {"bob": sec.SessionUserFlags(True, True, False)})
bob, _ = sec.create_session_token("bob")
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
for path in ("/api/v1/journals", "/api/v1/analysis/performance",
"/api/v1/market-intelligence/dashboard", "/api/v1/market-data/price/005930",
"/api/v1/ml-ensemble/predict/005930"):
r = await c.get(path)
assert r.status_code == 401, (path, r.status_code)
r = await c.post("/api/v1/risk/mdd/reset", headers={"X-API-Key": bob})
assert r.status_code == 403
r = await c.post("/api/v1/daytrading/close-all", headers={"X-API-Key": bob})
assert r.status_code == 403
r = await c.post("/api/admin/reset-daily-loss", headers={"X-API-Key": bob})
assert r.status_code == 403
# ─────────────────────────────────────────────────────────────────────────────
# M-2: 클라이언트 IP
# ─────────────────────────────────────────────────────────────────────────────
class _Req:
def __init__(self, peer, headers):
self.client = types.SimpleNamespace(host=peer) if peer else None
self.headers = {k.lower(): v for k, v in headers.items()}
def test_client_ip_ignores_spoofed_xff_and_trusts_proxy_real_ip():
from app.core.security import client_ip_v2
# 외부에서 직접: XFF/X-Real-IP 위조 무시
assert client_ip_v2(_Req("8.8.8.8", {"X-Forwarded-For": "1.2.3.4", "X-Real-IP": "5.6.7.8"})) == "8.8.8.8"
# 신뢰 프록시(nginx 컨테이너): X-Real-IP 사용, XFF 무시
assert client_ip_v2(_Req("172.18.0.5", {"X-Forwarded-For": "1.2.3.4", "X-Real-IP": "203.0.113.9"})) == "203.0.113.9"
# 신뢰 프록시인데 헤더 없음 / 형식 불량 → 소켓 주소
assert client_ip_v2(_Req("172.18.0.5", {})) == "172.18.0.5"
assert client_ip_v2(_Req("172.18.0.5", {"X-Real-IP": "not-an-ip"})) == "172.18.0.5"
def test_auth_login_ip_uses_client_ip_v2():
from app.api.endpoints import auth
assert auth._client_ip(_Req("8.8.8.8", {"X-Forwarded-For": "9.9.9.9"})) == "8.8.8.8"
def test_nginx_overwrites_forwarding_headers():
from pathlib import Path
conf = (Path(__file__).resolve().parents[2] / "frontend" / "nginx.conf").read_text()
# 백엔드로 가는 모든 proxy_pass 블록(/health, /api, /ws)이 전달 헤더를 덮어써야 한다
n_proxy = conf.count("proxy_pass http://backend:8000")
assert n_proxy == 3
assert conf.count("proxy_set_header X-Real-IP $remote_addr;") == n_proxy
assert conf.count("proxy_set_header X-Forwarded-For $remote_addr;") == n_proxy
assert "real_ip_recursive on;" in conf
def test_health_is_proxied_to_backend_not_spa():
"""Layout.tsx 의 fetch('/health') 가 SPA index.html(항상 200)에 응답받던 결함."""
from pathlib import Path
root = Path(__file__).resolve().parents[2]
conf = (root / "frontend" / "nginx.conf").read_text()
assert "location = /health {" in conf and "proxy_pass http://backend:8000/health;" in conf
assert "'/health': {" in (root / "frontend" / "vite.config.ts").read_text()
def test_cors_allows_patch():
from starlette.middleware.cors import CORSMiddleware
from app.main import app
mw = [m for m in app.user_middleware if m.cls is CORSMiddleware][0]
assert "PATCH" in mw.kwargs["allow_methods"]
# ─────────────────────────────────────────────────────────────────────────────
# M-6: WebSocket 토큰 로그 마스킹
# ─────────────────────────────────────────────────────────────────────────────
def test_uvicorn_ws_log_token_redacted():
from app.core.logging_config import QueryTokenRedactionFilter
rec = logging.LogRecord(
"uvicorn.error", logging.INFO, __file__, 1, '%s - "WebSocket %s" [accepted]',
(("1.2.3.4", 5), "/ws/abc?token=eyJ.SECRET.sig&x=1"), None,
)
QueryTokenRedactionFilter().filter(rec)
msg = rec.getMessage()
assert "SECRET" not in msg and "token=***" in msg and "x=1" in msg
def test_redaction_filter_installed_on_uvicorn_loggers():
import app.main # noqa: F401 — import 시 설치
from app.core.logging_config import QueryTokenRedactionFilter
for name in ("uvicorn.error", "uvicorn.access"):
assert any(isinstance(f, QueryTokenRedactionFilter) for f in logging.getLogger(name).filters)
# ─────────────────────────────────────────────────────────────────────────────
# L-4: KRX 휴장
# ─────────────────────────────────────────────────────────────────────────────
def test_krx_specific_holidays(monkeypatch):
from app.core.market_schedule import is_krx_holiday
assert is_krx_holiday(date(2026, 5, 1)) # 근로자의날
assert is_krx_holiday(date(2026, 12, 31)) # 연말 휴장 (목)
assert is_krx_holiday(date(2026, 9, 25)) # 추석 (holidays.KR)
assert not is_krx_holiday(date(2026, 10, 2)) # 평일
# 12/31 이 일요일인 2028 → 직전 영업일 12/29(금)
assert is_krx_holiday(date(2028, 12, 29))
assert not is_krx_holiday(date(2028, 12, 28))
monkeypatch.setattr(settings, "KRX_EXTRA_HOLIDAYS", "2026-10-07, bad")
assert is_krx_holiday(date(2026, 10, 7))
def test_ai_strategy_scheduler_checks_holiday():
import inspect
from app import main
assert "is_krx_holiday(today)" in inspect.getsource(main.lifespan)
# ─────────────────────────────────────────────────────────────────────────────
# 3회차 — M-A: in-flight close/upsert 재실행 멱등성
# ─────────────────────────────────────────────────────────────────────────────
class _Row:
def __init__(self, id, status="active", remaining_pct=1.0):
self.id = id
self.status = status
self.remaining_pct = remaining_pct
self.updated_at = None
class _FakeActiveTradeSession:
"""select(ActiveTrade) 를 흉내 — id 고정 조회와 '가장 최근 active' 조회를 구분한다."""
def __init__(self, rows):
self.rows = rows
self.commits = 0
async def execute(self, query):
text = str(query)
params = query.compile().params
actives = [r for r in self.rows if r.status == "active"]
if "active_trades.id =" in text:
pid = [v for k, v in params.items() if k.startswith("id")][0]
hit = [r for r in actives if r.id == pid]
else:
hit = sorted(actives, key=lambda r: r.id, reverse=True)[:1]
class _R:
def scalars(self_inner):
class _S:
def first(self_s):
return hit[0] if hit else None
return _S()
return _R()
async def commit(self):
self.commits += 1
def add(self, obj):
self.rows.append(obj)
@pytest.mark.asyncio
async def test_close_active_trade_rerun_is_idempotent():
"""재현(3회차 검증): 같은 close 를 두 번 실행하면 다음 active 행까지 닫혔다."""
from app.services import db_worker as w
rows = [_Row(1), _Row(2)] # 2 = 가장 최근(예: 스윙), 1 = 같은 종목 다른 포지션(예: DT)
sess = _FakeActiveTradeSession(rows)
data = {"stock_code": "005930"}
await w._handle_close_active_trade(sess, data)
await w._handle_close_active_trade(sess, data) # 종료 drain 의 in-flight 재실행
assert [r.status for r in rows] == ["active", "closed"], "두 번째 실행이 다른 행을 닫으면 안 된다"
assert data["_target_id"] == 2
@pytest.mark.asyncio
async def test_close_active_trade_no_target_rerun_is_noop():
from app.services import db_worker as w
rows = []
sess = _FakeActiveTradeSession(rows)
data = {"stock_code": "005930"}
await w._handle_close_active_trade(sess, data)
rows.append(_Row(7)) # 그 사이 새 포지션 진입
await w._handle_close_active_trade(sess, data)
assert rows[0].status == "active", "처음에 대상이 없던 close 의 재실행이 새 포지션을 닫으면 안 된다"
@pytest.mark.asyncio
async def test_upsert_multiply_rerun_is_idempotent_but_retry_remultiplies():
from app.services import db_worker as w
row = _Row(5, remaining_pct=1.0)
sess = _FakeActiveTradeSession([row])
data = {"stock_code": "005930", "remaining_pct_multiply": 0.7}
await w._handle_upsert_active_trade(sess, data)
await w._handle_upsert_active_trade(sess, data) # 커밋 여부 불명 재실행
assert row.remaining_pct == pytest.approx(0.7)
assert "_applied_remaining_pct" not in row.__dict__ and "_target_id" not in row.__dict__
# 정상 재시도 경로: 실패 실행의 절대값 마커를 지우고 다시 곱한다(그 사이 다른 갱신 보존)
task = {"type": "upsert_active_trade", "data": data}
w._strip_retry_markers(task)
row.remaining_pct = 0.5 # 다른 태스크가 먼저 반영
await w._handle_upsert_active_trade(sess, task["data"])
assert row.remaining_pct == pytest.approx(0.35)
@pytest.mark.asyncio
async def test_inflight_close_is_reexecuted_with_pin_not_dlq(fresh_queue, monkeypatch):
"""종료 drain: 처리 중 close 는 고정 id 로 재실행(멱등) — 다음 행을 닫지 않는다."""
w = fresh_queue
closed: list = []
dlq: list = []
async def _slow_close(session, data):
data["_target_id"] = 2
await asyncio.sleep(5) # 커밋 대기 중 cancel
async def _drain_close(session, data):
closed.append(data.get("_target_id"))
class _Sess:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def _fake_dlq(task, err):
dlq.append(task["type"])
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _Sess())
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
monkeypatch.setattr(w, "_handle_close_active_trade", _slow_close)
q = w.get_db_task_queue()
worker = asyncio.create_task(w.db_writer_worker())
await q.put({"type": "close_active_trade", "data": {"stock_code": "005930"}})
await asyncio.sleep(0.1)
monkeypatch.setattr(w, "_handle_close_active_trade", _drain_close)
worker.cancel()
await worker
assert closed == [2], "drain 재실행이 고정된 대상 id 를 써야 한다"
assert dlq == []
# ─────────────────────────────────────────────────────────────────────────────
# 3회차 — M-B: 커밋 여부 불명 DLQ 의 재큐잉 검증
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def dlq_dirs(tmp_path, monkeypatch):
from app.services import db_worker as w
monkeypatch.setattr(w, "DLQ_DIR", tmp_path)
monkeypatch.setattr(w, "DLQ_PROCESSED_DIR", tmp_path / "processed")
monkeypatch.setattr(w, "DLQ_EXPIRED_DIR", tmp_path / "expired")
monkeypatch.setattr(w, "DLQ_MANUAL_DIR", tmp_path / "manual")
requeued: list = []
async def _enq(task):
requeued.append(task)
monkeypatch.setattr(w, "enqueue_db_write", _enq)
return w, tmp_path, requeued
def _write_dlq(path, name, task, error):
import json
(path / name).write_text(json.dumps({
"task": task, "error": error, "failed_at": datetime.now().isoformat(), "retry_count": 0,
}))
@pytest.mark.asyncio
async def test_commit_unknown_insert_without_order_id_goes_manual(dlq_dirs):
w, d, requeued = dlq_dirs
_write_dlq(d, "a.json", {"type": "insert_trade_history", "data": {"stock_code": "005930"}},
w._DLQ_COMMIT_UNKNOWN_REASON)
_write_dlq(d, "b.json", {"type": "insert_trade_journal", "data": {"stock_code": "005930"}},
w._DLQ_COMMIT_UNKNOWN_REASON)
assert await w.replay_dlq() == 0
assert requeued == []
assert sorted(p.name for p in (d / "manual").iterdir()) == ["a.json", "b.json"]
@pytest.mark.asyncio
async def test_commit_unknown_insert_with_existing_order_id_is_quarantined(dlq_dirs, monkeypatch):
w, d, requeued = dlq_dirs
seen = []
async def _exists(data, entry):
seen.append(data["order_id"])
return data["order_id"] == "0001"
monkeypatch.setattr(w, "_dlq_trade_history_exists", _exists)
for name, oid in (("a.json", "0001"), ("b.json", "0002")):
_write_dlq(d, name, {"type": "insert_trade_history",
"data": {"stock_code": "005930", "order_id": oid, "order_type": "BUY"}},
w._DLQ_COMMIT_UNKNOWN_REASON)
assert await w.replay_dlq() == 1
assert [t["data"]["order_id"] for t in requeued] == ["0002"]
assert [p.name for p in (d / "expired").iterdir()] == ["a.json"]
assert sorted(seen) == ["0001", "0002"]
@pytest.mark.asyncio
async def test_commit_unknown_insert_db_unavailable_goes_manual(dlq_dirs, monkeypatch):
w, d, requeued = dlq_dirs
async def _boom(data, entry):
raise RuntimeError("db down")
monkeypatch.setattr(w, "_dlq_trade_history_exists", _boom)
_write_dlq(d, "a.json", {"type": "insert_trade_history",
"data": {"stock_code": "005930", "order_id": "0001"}},
w._DLQ_COMMIT_UNKNOWN_REASON)
assert await w.replay_dlq() == 0
assert requeued == []
assert [p.name for p in (d / "manual").iterdir()] == ["a.json"]
@pytest.mark.asyncio
async def test_normal_dlq_insert_duplicate_order_id_is_quarantined(dlq_dirs, monkeypatch):
"""사유와 무관하게 같은 order_id 행이 이미 있으면 재큐잉하지 않는다(_dlq_state_conflict)."""
w, d, requeued = dlq_dirs
async def _exists(data, entry):
return True
monkeypatch.setattr(w, "_dlq_trade_history_exists", _exists)
_write_dlq(d, "a.json", {"type": "insert_trade_history",
"data": {"stock_code": "005930", "order_id": "0001"}}, "db down")
assert await w.replay_dlq() == 0
assert [p.name for p in (d / "expired").iterdir()] == ["a.json"]
# ─────────────────────────────────────────────────────────────────────────────
# 3회차 — M-D: 워커 종료 후 enqueue / 종료 순서
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_enqueue_after_worker_shutdown_goes_to_dlq(fresh_queue, monkeypatch):
w = fresh_queue
dlq: list = []
async def _fake_dlq(task, err):
dlq.append((task["type"], err))
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
worker = asyncio.create_task(w.db_writer_worker())
await asyncio.sleep(0.05)
worker.cancel()
await worker
await w.enqueue_trade_history({"stock_code": "LATE"}) # shield 된 청산 마무리가 늦게 도착
assert dlq == [("insert_trade_history", "enqueued_after_worker_shutdown")]
assert w.get_db_task_queue().qsize() == 0
@pytest.mark.asyncio
async def test_await_background_tasks_before_worker(monkeypatch):
from app import main
done = []
async def _bg():
await asyncio.sleep(0.05)
done.append(1)
async def _hang():
await asyncio.sleep(60)
monkeypatch.setattr(main, "_background_tasks", set())
main._spawn_background(_bg())
t = main._spawn_background(_hang())
started = time.monotonic()
await main._await_background_tasks(0.3)
assert done == [1]
assert t.cancelled() and time.monotonic() - started < 2
def test_shutdown_awaits_background_before_db_worker_within_grace():
import inspect
from app import main
from app.services.db_worker import DRAIN_DEADLINE_SECONDS
block = inspect.getsource(main.lifespan)
block = block[block.index("# 태스크 정리"):]
assert block.index('"__background__"') < block.index('"db_worker_task"')
assert settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS + DRAIN_DEADLINE_SECONDS < _compose_stop_grace()
# ─────────────────────────────────────────────────────────────────────────────
# 3회차 — 회귀 가드: 공개 허용 목록 외 모든 라우트는 require_api_key
# ─────────────────────────────────────────────────────────────────────────────
_PUBLIC_ALLOWLIST = {
("POST", "/api/v1/auth/login"),
("POST", "/api/v1/auth/register"),
("GET", "/api/v1/market/indices"),
("GET", "/"),
("GET", "/health"),
}
# FastAPI 가 자동 등록하는 문서 라우트 (DEBUG 무관 기본 활성)
_FRAMEWORK_ROUTES = {"/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
# APIRoute 가 아닌 라우트(WebSocket·Starlette Route·Mount) — 전수 목록. 새로 생기면 이 테스트가 실패한다.
# /ws/{client_id}: 핸들러 안에서 is_valid_credential_v2 로 인증(아래 WS 테스트가 4001 을 확인)
_NON_API_ROUTES_ALLOWED = {"/ws/{client_id}", "/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
def test_no_unexpected_non_api_routes():
from fastapi.routing import APIRoute
from app.main import app
others = {getattr(r, "path", repr(r)) for r in app.routes if not isinstance(r, APIRoute)}
assert others <= _NON_API_ROUTES_ALLOWED, f"검토되지 않은 비-API 라우트: {sorted(others - _NON_API_ROUTES_ALLOWED)}"
assert "/ws/{client_id}" in others
def test_every_non_public_route_requires_api_key():
from fastapi.routing import APIRoute
from app.main import app
deps = _route_dep_calls()
open_routes = []
for r in app.routes:
if not isinstance(r, APIRoute) or r.path in _FRAMEWORK_ROUTES:
continue
for m in r.methods:
key = (m, r.path)
if key in _PUBLIC_ALLOWLIST:
continue
calls = deps.get(key, set())
if not ({"require_api_key", "require_admin_v2"} & calls):
open_routes.append(key)
assert open_routes == [], f"인증 없이 열린 라우트(공개 허용 목록 외): {sorted(open_routes)}"
# 허용 목록이 실재하는지(경로가 바뀌면 이 가드가 조용히 약해지지 않게)
for key in _PUBLIC_ALLOWLIST:
assert key in deps, f"공개 허용 목록의 라우트가 없음: {key}"
# ─────────────────────────────────────────────────────────────────────────────
# 4회차 — M1: drain 예산은 항목 실행 중에도 강제된다 (실제 시간 검증)
# ─────────────────────────────────────────────────────────────────────────────
class _HangSession:
def __init__(self, delay):
self.delay = delay
async def __aenter__(self):
await asyncio.sleep(self.delay) # asyncpg connect timeout(≈60s) 대체
return self
async def __aexit__(self, *a):
return False
@pytest.mark.asyncio
async def test_drain_hung_db_respects_budget_and_dlqs_everything(monkeypatch):
from app.services import db_worker as w
monkeypatch.setattr(w, "DRAIN_DEADLINE_SECONDS", 0.3)
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _HangSession(5))
saved = []
async def _fake_dlq(task, err):
saved.append((task["data"]["stock_code"], err))
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
q = asyncio.Queue()
q.put_nowait({"type": "insert_trade_history", "data": {"stock_code": "S0"}})
for i in (1, 2):
q.put_nowait({"type": "close_active_trade", "data": {"stock_code": f"S{i}"}})
t0 = time.monotonic()
await w._drain_on_shutdown_impl(q, None, [], set())
elapsed = time.monotonic() - t0
assert elapsed < 1.0, f"예산 0.3s 인데 {elapsed:.2f}s (수정 전: 항목당 세션 대기 전부 소비)"
assert [c for c, _ in saved] == ["S0", "S1", "S2"], "남은 항목이 모두 DLQ 에 남아야 한다"
# 커밋 중 끊긴 비멱등 INSERT 는 '커밋 여부 불명' — replay 가 중복 확인 후에만 재큐잉
assert saved[0][1] == w._DLQ_COMMIT_UNKNOWN_REASON
assert saved[1][1] == saved[2][1] == "shutdown_drain_deadline_exceeded"
@pytest.mark.asyncio
async def test_second_drain_pass_shares_first_budget(monkeypatch):
from app.services import db_worker as w
opened = []
def _session():
opened.append(1)
return _HangSession(0)
monkeypatch.setattr(w, "AsyncSessionLocal", _session)
saved = []
async def _fake_dlq(task, err):
saved.append(err)
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
q = asyncio.Queue()
q.put_nowait({"type": "close_active_trade", "data": {"stock_code": "A"}})
await w._drain_on_shutdown_impl(
q, None, [], set(), drain_started=time.monotonic() - w.DRAIN_DEADLINE_SECONDS - 1
)
assert opened == [] and saved == ["shutdown_drain_deadline_exceeded"]
import inspect
src = inspect.getsource(w.db_writer_worker)
assert src.count("drain_started=_drain_started") == 2, "워커의 두 drain 호출이 같은 시작 시각을 공유해야 한다"
@pytest.mark.asyncio
async def test_dlq_only_types_do_not_open_session(monkeypatch):
from app.services import db_worker as w
def _no_session():
raise AssertionError("DLQ 전용 타입이 세션을 열면 DB 행 시 예산을 잡아먹는다")
monkeypatch.setattr(w, "AsyncSessionLocal", _no_session)
saved = []
async def _fake_dlq(task, err):
saved.append((task["type"], err))
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
q = asyncio.Queue()
q.put_nowait({"type": "upsert_stock_analysis", "data": {}})
q.put_nowait({"type": "weird", "data": {}})
await w._drain_on_shutdown_impl(q, None, [], set())
assert saved == [("upsert_stock_analysis", "shutdown_drain"), ("weird", "shutdown_drain_unknown_type")]
# ─────────────────────────────────────────────────────────────────────────────
# 4회차 — L4: log_mcp_deny 는 재실행하지 않는다
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_inflight_log_mcp_deny_not_reexecuted_and_not_requeued(monkeypatch, dlq_dirs):
w, d, requeued = dlq_dirs
saved = []
async def _fake_dlq(task, err):
saved.append((task["type"], err))
monkeypatch.setattr(w, "_save_to_dlq", _fake_dlq)
await w._drain_on_shutdown_impl(asyncio.Queue(), {"type": "log_mcp_deny", "data": {}}, [], set())
assert saved == [("log_mcp_deny", w._DLQ_COMMIT_UNKNOWN_REASON)]
_write_dlq(d, "m.json", {"type": "log_mcp_deny", "data": {"stock_code": "x"}}, w._DLQ_COMMIT_UNKNOWN_REASON)
assert await w.replay_dlq() == 0 and requeued == []
assert [p.name for p in (d / "expired").iterdir()] == ["m.json"]
# ─────────────────────────────────────────────────────────────────────────────
# 4회차 — L2: 주문번호 중복 확인은 같은 날만
# ─────────────────────────────────────────────────────────────────────────────
def test_dlq_ref_day_bounds_same_day():
from app.services import db_worker as w
start, end = w._dlq_ref_day_bounds({"created_at": "2026-10-01T15:10:00"}, {})
assert (start, end) == (datetime(2026, 10, 1), datetime(2026, 10, 2))
start, _ = w._dlq_ref_day_bounds({"order_date": "2026-09-30", "created_at": "2026-10-01T09:00:00"}, {})
assert start == datetime(2026, 9, 30)
# 날짜 단서 없음 → failed_at (재처리 시점 '오늘'이 아니다)
start, _ = w._dlq_ref_day_bounds({}, {"failed_at": "2026-09-29T23:59:00"})
assert start == datetime(2026, 9, 29)
# ─────────────────────────────────────────────────────────────────────────────
# 4회차 — L3: 커밋 여부 불명 일지는 존재 확인 후에만 재큐잉
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("exists, dest, n_requeued", [(True, "expired", 0), (None, "manual", 0), (False, None, 1)])
@pytest.mark.asyncio
async def test_commit_unknown_journal_verified_before_requeue(dlq_dirs, monkeypatch, exists, dest, n_requeued):
w, d, requeued = dlq_dirs
async def _exists(data, entry):
return exists
monkeypatch.setattr(w, "_dlq_trade_journal_exists", _exists)
_write_dlq(d, "j.json", {"type": "insert_trade_journal",
"data": {"stock_code": "005930", "order_id": "0001"}},
w._DLQ_COMMIT_UNKNOWN_REASON)
assert await w.replay_dlq() == n_requeued
assert len(requeued) == n_requeued
if dest:
assert [p.name for p in (d / dest).iterdir()] == ["j.json"]
@pytest.mark.asyncio
async def test_commit_unknown_journal_check_error_goes_manual(dlq_dirs, monkeypatch):
w, d, requeued = dlq_dirs
async def _boom(data, entry):
raise RuntimeError("db down")
monkeypatch.setattr(w, "_dlq_trade_journal_exists", _boom)
_write_dlq(d, "j.json", {"type": "insert_trade_journal", "data": {"trade_id": 9}},
w._DLQ_COMMIT_UNKNOWN_REASON)
assert await w.replay_dlq() == 0
assert [p.name for p in (d / "manual").iterdir()] == ["j.json"]
# ─────────────────────────────────────────────────────────────────────────────
# 4회차 — L1: DLQ 건수 노출 (내용 비공개)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_dlq_status_counts_and_health_field(dlq_dirs, monkeypatch):
w, d, _ = dlq_dirs
_write_dlq(d, "p.json", {"type": "x", "data": {}}, "e")
(d / "manual").mkdir()
_write_dlq(d / "manual", "m1.json", {"type": "x", "data": {}}, "e")
_write_dlq(d / "manual", "m2.json", {"type": "x", "data": {}}, "e")
assert await w.dlq_status_counts() == {"pending": 1, "manual": 2, "expired": 0}
# /health 는 DB·Redis 를 실제로 확인하므로 유닛에서는 응답 구조를 소스로 고정한다
# (건수만 노출, expired 비노출 — 정보 최소화)
import inspect
from app import main
src = inspect.getsource(main._health_check_impl)
assert '"dlq": dlq_info' in src and '"manual": _dlq["manual"]' in src
assert "_dlq[\"expired\"]" not in src.split("dlq_info = {")[1].split("}")[0]
# ─────────────────────────────────────────────────────────────────────────────
# 4회차 — L5: WebSocket 인증 (DEBUG=False)
# ─────────────────────────────────────────────────────────────────────────────
def _ws_connect(path):
from starlette.testclient import TestClient
from app.main import app
client = TestClient(app) # context manager 미사용 → lifespan(스케줄러·KIS) 미기동
with client.websocket_connect(path) as ws:
ws.receive_text()
@pytest.mark.parametrize("case", ["no_token", "revoked", "db_failure", "bad_token"])
def test_ws_auth_rejects_with_4001(monkeypatch, case):
from starlette.websockets import WebSocketDisconnect
from app.core import security as sec
monkeypatch.setattr(settings, "DEBUG", False)
sec.invalidate_session_user_cache()
path = "/ws/c1"
if case == "revoked":
async def _flags(u):
return sec.SessionUserFlags(True, False, False)
monkeypatch.setattr(sec, "_fetch_session_user_flags_impl", _flags)
path += "?token=" + sec.create_session_token("bob")[0]
elif case == "db_failure":
async def _boom(u):
raise RuntimeError("db down")
monkeypatch.setattr(sec, "_fetch_session_user_flags_impl", _boom)
path += "?token=" + sec.create_session_token("carol")[0]
elif case == "bad_token":
path += "?token=not-a-token"
try:
with pytest.raises(WebSocketDisconnect) as e:
_ws_connect(path)
assert e.value.code == 4001
finally:
sec.invalidate_session_user_cache()
# ─────────────────────────────────────────────────────────────────────────────
# 4회차 — Redis 소켓 타임아웃
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_redis_client_has_socket_timeouts(monkeypatch):
# 다른 테스트가 sys.modules 의 app.core.redis 를 목으로 바꿔 두는 경우가 있어
# (순서 의존 — CLAUDE.md §4.1) 소스 파일에서 격리된 사본을 로드해 검사한다.
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"_redis_under_test", Path(__file__).resolve().parents[1] / "app" / "core" / "redis.py"
)
r = importlib.util.module_from_spec(spec)
spec.loader.exec_module(r)
captured = {}
class _Client:
async def ping(self):
return True
async def aclose(self):
pass
def _from_url(url, **kw):
captured.update(kw)
return _Client()
monkeypatch.setattr(r.aioredis, "from_url", _from_url)
monkeypatch.setattr(r, "_redis_client", None)
await r.init_redis()
assert captured["socket_timeout"] == settings.REDIS_SOCKET_TIMEOUT > 0
assert captured["socket_connect_timeout"] == settings.REDIS_SOCKET_CONNECT_TIMEOUT > 0
# ─────────────────────────────────────────────────────────────────────────────
# 5회차 — M-4: 주문 취소 파라미터 (공식 샘플 order_rvsecncl 대조)
# ─────────────────────────────────────────────────────────────────────────────
def _cancel_api(tmp_path, virtual=False, ccld_rows=None, ccld_rt="0"):
api = _bare_api(tmp_path)
api.base_url = "https://openapivts.example.invalid" if virtual else "https://openapi.example.invalid"
api.account_number = "1234567801"
api.cancel_order_v2 = types.MethodType(_ORIG_CANCEL_ORDER_V2, api)
api.cancel_order = types.MethodType(_ORIG_CANCEL_ORDER, api)
sent: list = []
lookups: list = []
async def _make_request(method, endpoint, tr_id, extra_headers=None, **kw):
if endpoint != kis_mod.KIS_CANCEL_ENDPOINT:
raise AssertionError(f"예상치 못한 KIS 호출: {endpoint}")
sent.append({"method": method, "tr_id": tr_id, "body": kw["json"]})
return {"rt_cd": "0", "msg1": "ok", "output": {"ODNO": "0000099999"}}, {}
async def _ccld(stock_code="", order_id="", **kw):
lookups.append((stock_code, order_id))
if ccld_rows is None:
raise AssertionError("조직번호를 알고 있는데 주문내역을 조회했다")
return {"rt_cd": ccld_rt, "output1": ccld_rows}, {}
api._make_request = _make_request
api._daily_ccld_fetch_v2 = _ccld
return api, sent, lookups
@pytest.mark.asyncio
async def test_cancel_v2_body_matches_official_sample(tmp_path):
api, sent, lookups = _cancel_api(tmp_path)
res = await api.cancel_order_v2("0000002101", org_no="06010")
assert res["rt_cd"] == "0" and lookups == []
req = sent[0]
assert req["method"] == "POST" and req["tr_id"] == "TTTC0013U"
assert req["body"] == {
"CANO": "12345678", "ACNT_PRDT_CD": "01",
"KRX_FWDG_ORD_ORGNO": "06010", "ORGN_ODNO": "0000002101",
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02",
"ORD_QTY": "0", "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y",
"EXCG_ID_DVSN_CD": "KRX",
}
# 공식 샘플의 필수 검증: 빈 문자열 금지
assert all(v != "" for v in req["body"].values())
@pytest.mark.asyncio
async def test_cancel_v2_partial_quantity_and_virtual_tr(tmp_path):
api, sent, _ = _cancel_api(tmp_path, virtual=True)
await api.cancel_order_v2("2101", org_no="06010", quantity=3)
body = sent[0]["body"]
assert sent[0]["tr_id"] == "VTTC0013U"
assert body["QTY_ALL_ORD_YN"] == "N" and body["ORD_QTY"] == "3"
@pytest.mark.asyncio
async def test_legacy_cancel_order_looks_up_org_no_from_todays_orders(tmp_path):
rows = [
{"odno": "0000001111", "ord_gno_brno": "99999"},
{"odno": "0000002101", "ord_gno_brno": "06010"},
]
api, sent, lookups = _cancel_api(tmp_path, ccld_rows=rows)
await api.cancel_order("2101", 5) # 레거시 시그니처: 0 패딩 차이 흡수 + 잔량 전부 취소 유지
assert lookups == [("", "2101")]
body = sent[0]["body"]
assert body["KRX_FWDG_ORD_ORGNO"] == "06010"
assert body["QTY_ALL_ORD_YN"] == "Y" and body["ORD_QTY"] == "0"
@pytest.mark.parametrize("rows, rt", [([], "0"), ([{"odno": "2101", "ord_gno_brno": ""}], "0"), ([], "1")])
@pytest.mark.asyncio
async def test_cancel_not_sent_when_org_no_unknown(tmp_path, caplog, rows, rt):
api, sent, _ = _cancel_api(tmp_path, ccld_rows=rows, ccld_rt=rt)
with caplog.at_level(logging.WARNING):
res = await api.cancel_order_v2("2101")
assert sent == [], "조직번호 없이 취소를 전송하면 실전에서 거부된다"
assert res["rt_cd"] == "-1" and res["msg_cd"] == kis_mod.KIS_PRESEND_ORGNO_NOT_FOUND
assert any(r.levelno >= logging.WARNING for r in caplog.records)
@pytest.mark.asyncio
async def test_cancel_rejection_logged_warning(tmp_path, caplog):
api, sent, _ = _cancel_api(tmp_path)
async def _reject(method, endpoint, tr_id, extra_headers=None, **kw):
sent.append(kw["json"])
return {"rt_cd": "1", "msg_cd": "APBK0918", "msg1": "취소 가능 수량 없음"}, {}
api._make_request = _reject
with caplog.at_level(logging.WARNING):
res = await api.cancel_order_v2("2101", org_no="06010")
assert res["rt_cd"] == "1"
assert any("취소 거부" in r.getMessage() and r.levelno >= logging.WARNING for r in caplog.records)
@pytest.mark.asyncio
async def test_cancel_presend_token_failure_tagged(tmp_path):
api, sent, _ = _cancel_api(tmp_path)
async def _unavailable(*a, **k):
raise kis_mod.KISTokenUnavailableError("wait", retry_after_sec=10)
api._make_request = _unavailable
res = await api.cancel_order_v2("2101", org_no="06010")
assert res["msg_cd"] == kis_mod.KIS_PRESEND_TOKEN_UNAVAILABLE and res["rt_cd"] == "-1"
def test_extract_order_org_no_from_place_order_result():
f = kis_mod.extract_order_org_no
assert f({"rt_cd": "0", "output": {"KRX_FWDG_ORD_ORGNO": "06010", "ODNO": "0000002101"}}) == "06010"
assert f({"rt_cd": "0", "output": [{"KRX_FWDG_ORD_ORGNO": "91252"}]}) == "91252"
assert f({"rt_cd": "1", "output": None}) is None
assert f(None) is None
# ─────────────────────────────────────────────────────────────────────────────
# 5회차 — M-3: 연패 밴은 종료 drain 에서 커밋 + replay 후 loss_tracker 재로드
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("ttype, handler", [
("upsert_stock_ban", "_handle_upsert_stock_ban"),
("upsert_stock_ban_v2", "_handle_upsert_stock_ban_v2"),
])
@pytest.mark.asyncio
async def test_drain_commits_stock_ban(monkeypatch, ttype, handler):
from app.services import db_worker as w
called = []
class _Sess:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def _h(session, data):
called.append(data["stock_code"])
async def _no_dlq(task, err):
raise AssertionError(f"밴이 DLQ 로 빠졌다: {err}")
monkeypatch.setattr(w, "AsyncSessionLocal", lambda: _Sess())
monkeypatch.setattr(w, handler, _h)
monkeypatch.setattr(w, "_save_to_dlq", _no_dlq)
q = asyncio.Queue()
q.put_nowait({"type": ttype, "data": {"stock_code": "005930", "consecutive_losses": 3}})
await w._drain_on_shutdown_impl(q, None, [], set())
assert called == ["005930"]
def test_normal_path_uses_same_ban_handlers():
import inspect
from app.services import db_worker as w
src = inspect.getsource(w._execute_db_task_impl)
assert "_handle_upsert_stock_ban(session, data)" in src
assert "_handle_upsert_stock_ban_v2(session, data)" in src
@pytest.mark.asyncio
async def test_replay_records_requeued_types(dlq_dirs):
w, d, requeued = dlq_dirs
_write_dlq(d, "b.json", {"type": "upsert_stock_ban",
"data": {"stock_code": "005930", "consecutive_losses": 3}}, "shutdown_drain")
assert await w.replay_dlq() == 1
assert "upsert_stock_ban" in w.last_replay_requeued_types
@pytest.mark.asyncio
async def test_bans_reloaded_after_replay_commit(monkeypatch, fresh_queue):
from app import main
from app.services import db_worker as w
from app.services import loss_tracker as lt_mod
order = []
class _Sess:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def _load(session):
order.append("reload")
monkeypatch.setattr(lt_mod.loss_tracker, "load_from_db", _load)
monkeypatch.setattr(main, "AsyncSessionLocal", lambda: _Sess())
monkeypatch.setattr(w, "last_replay_requeued_types", {"upsert_stock_ban"})
q = w.get_db_task_queue()
await q.put({"type": "upsert_stock_ban", "data": {}})
async def _consumer():
await asyncio.sleep(0.05)
q.get_nowait()
order.append("committed")
q.task_done()
asyncio.create_task(_consumer())
await main._reload_bans_after_replay()
assert order == ["committed", "reload"], "재큐잉된 밴이 커밋된 뒤 재로드해야 한다"
# 밴이 재큐잉되지 않았으면 재로드하지 않는다
order.clear()
monkeypatch.setattr(w, "last_replay_requeued_types", {"insert_trade_history"})
await main._reload_bans_after_replay()
assert order == []
def test_lifespan_reloads_bans_after_replay():
import inspect
from app import main
src = inspect.getsource(main.lifespan)
assert src.index("await replay_dlq()") < src.index("await _reload_bans_after_replay()")
# ─────────────────────────────────────────────────────────────────────────────
# 5회차 — L-3: 종료 예산 합계 검증 / L-4: /health 캐시 / L-7: Redis 축출 정책
# ─────────────────────────────────────────────────────────────────────────────
def test_shutdown_budget_check(monkeypatch):
from app import main
assert main.check_shutdown_budget() == []
monkeypatch.setattr(settings, "SHUTDOWN_BACKGROUND_DRAIN_SECONDS", 30)
issues = main.check_shutdown_budget()
assert issues and "종료 유예" in issues[0]
import inspect
assert "check_shutdown_budget()" in inspect.getsource(main.lifespan)
@pytest.mark.asyncio
async def test_health_is_cached_and_coalesced(monkeypatch):
from app import main
calls = []
async def _impl():
calls.append(1)
await asyncio.sleep(0.05)
return {"status": "healthy", "n": len(calls)}
monkeypatch.setattr(main, "_health_check_impl", _impl)
monkeypatch.setattr(main, "_health_cache", {"at": 0.0, "value": None})
monkeypatch.setattr(settings, "HEALTH_CACHE_SECONDS", 1.5)
res = await asyncio.gather(*[main.health_check() for _ in range(20)])
assert len(calls) == 1 and all(r == res[0] for r in res)
monkeypatch.setattr(settings, "HEALTH_CACHE_SECONDS", 0)
await main.health_check()
assert len(calls) == 2
def test_redis_eviction_spares_ttl_less_state_keys():
from pathlib import Path
import yaml
compose = yaml.safe_load((Path(__file__).resolve().parents[2] / "docker-compose.yml").read_text())
cmd = compose["services"]["redis"]["command"]
assert "--maxmemory-policy volatile-lru" in cmd and "allkeys" not in cmd
# ─────────────────────────────────────────────────────────────────────────────
# 6회차 — M-1: 주문 엔드포인트 5xx + JSON 거부 본문은 '결과 불명'이 아니라 응답 dict
# ─────────────────────────────────────────────────────────────────────────────
class _HtmlResp(_Resp):
def json(self):
raise ValueError("not json")
_ORDER_EP = "/uapi/domestic-stock/v1/trading/order-cash"
@pytest.mark.parametrize("status", [500, 503])
@pytest.mark.asyncio
async def test_order_5xx_with_json_rejection_returns_dict(tmp_path, status):
body = {"rt_cd": "1", "msg_cd": "EGW00201", "msg1": "초당 거래건수를 초과하였습니다."}
client = _Client([_Resp(status, body)])
api = _api_for_request(tmp_path, client, ["T"])
data, _ = await api._make_request_with_retry("POST", _ORDER_EP, "TTTC0802U", json={})
assert data == body
assert len(client.auth_headers) == 1, "주문은 재전송하지 않는다"
@pytest.mark.parametrize("resp", [
_HtmlResp(500, None), # HTML/빈 본문
_Resp(502, {"error": "bad gateway"}), # JSON 이지만 KIS 응답 형식 아님
])
@pytest.mark.asyncio
async def test_order_5xx_without_kis_body_still_raises(tmp_path, resp):
client = _Client([resp])
api = _api_for_request(tmp_path, client, ["T"])
with pytest.raises(Exception, match="최대 재시도 초과"):
await api._make_request_with_retry("POST", _ORDER_EP, "TTTC0802U", json={})
assert len(client.auth_headers) == 1
@pytest.mark.asyncio
async def test_query_5xx_with_json_behavior_unchanged(tmp_path):
"""조회 계열은 5xx 면 기존대로 재시도 후 예외 (본문이 KIS 형식이어도)."""
body = {"rt_cd": "1", "msg_cd": "EGW00201"}
client = _Client([_Resp(500, body) for _ in range(4)])
api = _api_for_request(tmp_path, client, ["T"])
with pytest.raises(Exception, match="최대 재시도 초과"):
await api._make_request_with_retry("GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "TR")
assert len(client.auth_headers) == api.max_retries + 1