"""하드닝 R6: DB alembic_version ↔ 코드 head 대조가 /health 에 노출된다.""" import pytest from app import main as m class _Res: def __init__(self, rows): self._rows = rows def fetchall(self): return self._rows class _Conn: def __init__(self, rows=None, exc=None): self.rows, self.exc = rows, exc async def execute(self, *_a, **_k): if self.exc: raise self.exc return _Res(self.rows) @pytest.fixture(autouse=True) def _reset_state(monkeypatch): monkeypatch.setattr(m, "_migration_state", {"checked": False, "db": None, "head": None, "mismatch": False}) def test_code_head_readable(): assert len(m._code_alembic_heads_v2()) >= 1 @pytest.mark.asyncio async def test_match_is_not_mismatch(): heads = m._code_alembic_heads_v2() st = await m._check_migration_head_v2(_Conn([(h,) for h in heads])) assert st["mismatch"] is False and st["checked"] is True @pytest.mark.asyncio async def test_old_db_version_is_mismatch(): st = await m._check_migration_head_v2(_Conn([("0011",)])) assert st["mismatch"] is True and st["db"] == "0011" @pytest.mark.asyncio async def test_missing_table_raises_not_silent(): with pytest.raises(RuntimeError): await m._check_migration_head_v2(_Conn(exc=RuntimeError("no alembic_version"))) assert m._migration_state["mismatch"] is False # 불일치로 단정하지 않는다 @pytest.mark.asyncio async def test_health_degraded_reason_on_mismatch(monkeypatch): from app.services import db_worker as w from app.core import redis as _redis class _NoDb: def connect(self): raise RuntimeError("test: real DB access blocked") monkeypatch.setattr(m, "engine", _NoDb()) monkeypatch.setattr(_redis, "get_redis", lambda: None) async def dlq(): return {"pending": 0, "manual": 0, "expired": 0} monkeypatch.setattr(w, "dlq_status_counts", dlq) monkeypatch.setattr(m, "_runtime_health_v2", lambda market_open=None: ({}, [])) m._migration_state["mismatch"] = True out = await m._health_check_impl() assert "migration_mismatch" in out["degraded_reasons"] and out["status"] == "degraded" @pytest.mark.asyncio async def test_mismatch_blocks_entry_independent_of_fail_closed(monkeypatch): """R8: SECURITY_FAIL_CLOSED=False 여도 불일치면 can_open_new_position 이 False.""" from app.core.config import settings monkeypatch.setattr(settings, "SECURITY_FAIL_CLOSED", False) class _Mgr: async def can_open_new_position(self): return True mgr = _Mgr() m._install_migration_entry_guard_v2(mgr) m._migration_state["mismatch"] = False assert await mgr.can_open_new_position() is True m._migration_state["mismatch"] = True assert await mgr.can_open_new_position() is False m._migration_state["mismatch"] = False # 복구 시 자동 해제 assert await mgr.can_open_new_position() is True @pytest.mark.asyncio async def test_guard_install_is_idempotent(): class _Mgr: async def can_open_new_position(self): return True mgr = _Mgr() m._install_migration_entry_guard_v2(mgr) first = mgr.can_open_new_position m._install_migration_entry_guard_v2(mgr) assert mgr.can_open_new_position is first @pytest.mark.asyncio async def test_recheck_loop_repeats_warning_and_recovers(monkeypatch): sent = [] monkeypatch.setattr(m, "_notify_system_warning_bg", lambda c, msg: sent.append(c)) states = iter([True, True, False]) async def _fake_check(_conn): m._migration_state.update(checked=True, db="0011", head="0012", mismatch=next(states)) return dict(m._migration_state) class _Ctx: async def __aenter__(self): return object() async def __aexit__(self, *a): return False class _Eng: def connect(self): return _Ctx() monkeypatch.setattr(m, "engine", _Eng()) monkeypatch.setattr(m, "_check_migration_head_v2", _fake_check) m._migration_state["mismatch"] = True await m._migration_recheck_loop_v2(interval_sec=0, iterations=3) assert sent == ["migration_mismatch", "migration_mismatch"] assert m._migration_state["mismatch"] is False @pytest.mark.asyncio async def test_recheck_failure_keeps_mismatch(monkeypatch): class _Eng: def connect(self): raise RuntimeError("db down") monkeypatch.setattr(m, "engine", _Eng()) m._migration_state["mismatch"] = True await m._migration_recheck_loop_v2(interval_sec=0, iterations=1) assert m._migration_state["mismatch"] is True