"""하드닝 R3: 종료 drain 의 주문 경로 대기 + WebSocket 세션 재검증/소유권.""" import asyncio import time import pytest from app import main as m from app.core.config import settings @pytest.fixture(autouse=True) def _iso(monkeypatch): calls = [] monkeypatch.setattr(m, "_notify_system_warning_bg", lambda c, msg: calls.append((c, msg))) monkeypatch.setattr(m, "_ws_owners", {}) return calls @pytest.mark.asyncio async def test_drain_waits_for_swing_inflight_and_fill_reconciler(monkeypatch): from app.services.swing import scheduler as sw from app.services.daytrading import fill_reconciler as fr done = [] async def _work(tag): await asyncio.sleep(0.05) done.append(tag) t1 = asyncio.ensure_future(_work("swing")) t2 = asyncio.ensure_future(_work("fill")) monkeypatch.setattr(sw, "_inflight_steps", {"entry": t1}) monkeypatch.setattr(fr, "_pending_tasks", {t2}) n = await m._drain_order_path_tasks_v2(float(settings.SHUTDOWN_BACKGROUND_DRAIN_SECONDS)) assert n == 0 and sorted(done) == ["fill", "swing"] @pytest.mark.asyncio async def test_drain_waits_for_shielded_inner_coroutine(monkeypatch): from app.services.swing import scheduler as sw from app.services.daytrading import fill_reconciler as fr monkeypatch.setattr(sw, "_inflight_steps", {}) monkeypatch.setattr(fr, "_pending_tasks", set()) finished = [] async def _place_and_finalize_exit_guarded_impl(): await asyncio.sleep(0.05) finished.append(1) outer = asyncio.ensure_future(asyncio.shield(_place_and_finalize_exit_guarded_impl())) await asyncio.sleep(0) outer.cancel() # 판정 코루틴 취소 — 내부 shield 작업은 계속 돈다 await m._drain_order_path_tasks_v2(5.0) assert finished == [1] @pytest.mark.asyncio async def test_drain_timeout_cancels_and_alerts(monkeypatch, _iso): from app.services.swing import scheduler as sw from app.services.daytrading import fill_reconciler as fr monkeypatch.setattr(fr, "_pending_tasks", set()) async def _stuck(): await asyncio.sleep(60) t = asyncio.ensure_future(_stuck()) monkeypatch.setattr(sw, "_inflight_steps", {"exit": t}) n = await m._drain_order_path_tasks_v2(0.05) assert n == 1 and t.cancelled() assert _iso and _iso[0][0] == "SHUTDOWN_ORDER_TASKS" and "_stuck" in _iso[0][1] @pytest.mark.asyncio async def test_all_drain_includes_order_path(monkeypatch): from app.services import trade_executor as te called = [] async def _od(timeout): called.append(timeout) return 0 async def _te(timeout): return 0 monkeypatch.setattr(m, "_drain_order_path_tasks_v2", _od) monkeypatch.setattr(te, "drain_tracked_tasks_v2", _te) await m._drain_all_background_tasks_v2(1.0) assert called == [1.0] def test_ws_exp_and_owner(): from app.core import security as sec tok, _ = sec.create_session_token("bob") exp = m._ws_token_exp_v2(tok) assert exp is not None and exp > time.time() assert m._ws_token_exp_v2("api-key-plain") is None m._ws_owners["c1"] = "bob" assert m._ws_owner_may_connect_v2("c1", "bob", {"c1": 1}) is True assert m._ws_owner_may_connect_v2("c1", "eve", {"c1": 1}) is False assert m._ws_owner_may_connect_v2("c2", "eve", {"c1": 1}) is True @pytest.mark.asyncio async def test_ws_session_revalidation(monkeypatch): from app.core import security as sec # exp 초과 assert await m._ws_session_still_valid_v2("t", time.time() - 1, recheck=False) is False assert await m._ws_session_still_valid_v2("t", time.time() + 100, recheck=False) is True async def _revoked(tok): return None monkeypatch.setattr(sec, "resolve_credential_v2", _revoked) assert await m._ws_session_still_valid_v2("t", None, recheck=True) is False async def _boom(tok): raise RuntimeError("db down") monkeypatch.setattr(sec, "resolve_credential_v2", _boom) assert await m._ws_session_still_valid_v2("t", None, recheck=True) is True # ── 하드닝 R5: WS 재확인 fail-closed / 요청 제한 ───────────────────────────── @pytest.mark.asyncio async def test_ws_recheck_closes_after_consecutive_failures(monkeypatch): from app.core import security async def _boom(token): raise RuntimeError("db down") monkeypatch.setattr(security, "resolve_credential_v2", _boom) st = {"fails": 0} limit = int(m._WS_RECHECK_MAX_FAILURES) for _ in range(limit - 1): assert await m._ws_session_still_valid_v3("t", None, True, st) is True assert await m._ws_session_still_valid_v3("t", None, True, st) is False @pytest.mark.asyncio async def test_ws_recheck_success_resets_counter(monkeypatch): from app.core import security mode = {"fail": True} async def _res(token): if mode["fail"]: raise RuntimeError("x") return object() monkeypatch.setattr(security, "resolve_credential_v2", _res) st = {"fails": 0} await m._ws_session_still_valid_v3("t", None, True, st) mode["fail"] = False assert await m._ws_session_still_valid_v3("t", None, True, st) is True assert st["fails"] == 0 def test_ws_cap_codes_limits_and_dedupes(): codes = [f"C{i}" for i in range(m._WS_MAX_SUBSCRIBE_CODES + 50)] + ["C0", " "] out = m._ws_cap_codes_v2(codes) assert len(out) == m._WS_MAX_SUBSCRIBE_CODES and len(set(out)) == len(out) def test_ws_chart_limiter_per_second_and_minute(): t = {"now": 0.0} lim = m._WsChartLimiterV2(2, 3, clock=lambda: t["now"]) assert lim.allow() and lim.allow() assert not lim.allow() # 초당 초과 t["now"] = 1.5 assert lim.allow() # 분당 3번째 t["now"] = 3.0 assert not lim.allow() # 분당 초과 t["now"] = 61.0 assert lim.allow()