feat(bat-020): daemon-scoped 브로커 인증정보 (REQ-046~051)
- REQ-046: BrokerCredential.daemon_id 필드 추가, repository 시그니처 갱신 - REQ-047: alembic revision Step1(daemon_id NULL 컬럼 추가), Step2(PK 전환, dev/test DB 한정) - REQ-048: backfill-daemon-id CLI subcommand (--dry-run/--force/--broker 지원) - REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달 (brokers→broker, daemon_id 없으면 403) - REQ-050: credential set/list/remove --daemon-id 옵션 추가 (daemon_slug 해석) - REQ-051: 보안 격리 - service-token 전용, web 토큰 차단, 로그 app_secret 비노출, graceful degrade 비가역 결재 게이트: REQ-047 Step2(PK 전환)는 사장님 결재 후 실DB 적용 예정(이 batch는 revision 코드만) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""BAT-020: REQ-046 BrokerCredential daemon_id 필드 단위 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.models import BrokerCredential
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-1: daemon_id=None 기본값
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_broker_credential_daemon_id_defaults_to_none() -> None:
|
||||
"""REQ-046 AC-1: daemon_id 미지정 시 None이 기본값."""
|
||||
cred = BrokerCredential(
|
||||
user_id="user-1",
|
||||
broker="kis",
|
||||
account_ref="12345678",
|
||||
encrypted_payload="encrypted",
|
||||
)
|
||||
assert hasattr(cred, "daemon_id")
|
||||
assert cred.daemon_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-2: daemon_id 값 지정
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_broker_credential_daemon_id_set() -> None:
|
||||
"""REQ-046 AC-2: daemon_id="daemon-abc" 지정 시 해당 값으로 세팅."""
|
||||
cred = BrokerCredential(
|
||||
user_id="user-1",
|
||||
broker="kis",
|
||||
account_ref="12345678",
|
||||
encrypted_payload="encrypted",
|
||||
daemon_id="daemon-abc",
|
||||
)
|
||||
assert cred.daemon_id == "daemon-abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-3: _row_to_broker_credential 7컬럼(daemon_id 포함) 처리
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_row_to_broker_credential_with_daemon_id() -> None:
|
||||
"""REQ-046 AC-3: 7컬럼 행(daemon_id 포함) 변환 시 daemon_id 세팅."""
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
row = ("user-1", "kis", "12345678", "encrypted", 2, now, "daemon-xyz")
|
||||
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
|
||||
assert cred.user_id == "user-1"
|
||||
assert cred.broker == "kis"
|
||||
assert cred.account_ref == "12345678"
|
||||
assert cred.encrypted_payload == "encrypted"
|
||||
assert cred.credential_version == 2
|
||||
assert cred.last_rotated_at == now
|
||||
assert cred.daemon_id == "daemon-xyz"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-4: _row_to_broker_credential 6컬럼(daemon_id 없음) 하위호환
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_row_to_broker_credential_without_daemon_id() -> None:
|
||||
"""REQ-046 AC-4: 6컬럼 행(daemon_id 없음) 변환 시 daemon_id=None (하위호환)."""
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
row = ("user-1", "kis", "12345678", "encrypted", 1, now)
|
||||
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
|
||||
assert cred.daemon_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-5/6: list_broker_credentials daemon_id 필터 (mock 기반)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeCursor:
|
||||
"""psycopg Cursor 최소 mock."""
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
self.last_query = None
|
||||
self.last_params = None
|
||||
|
||||
def execute(self, query, params=None):
|
||||
self.last_query = query
|
||||
self.last_params = params
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def cursor(self):
|
||||
return _FakeCursor(self._rows)
|
||||
|
||||
|
||||
def _make_rows_for_user(user_id: str):
|
||||
"""daemon_id "d1", "d2" 각각 1건씩."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return [
|
||||
(user_id, "kis", "ref-d1", "enc-d1", 1, now, "d1"),
|
||||
(user_id, "kis2", "ref-d2", "enc-d2", 1, now, "d2"),
|
||||
]
|
||||
|
||||
|
||||
def test_list_broker_credentials_daemon_id_filter() -> None:
|
||||
"""REQ-046 AC-5: daemon_id 필터 시 해당 daemon_id 행만 반환."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig
|
||||
|
||||
rows = _make_rows_for_user("user-1")
|
||||
# daemon_id="d1" 필터 시 d1 행만 반환되는 mock
|
||||
d1_rows = [rows[0]]
|
||||
|
||||
config = PortfolioDBConfig(
|
||||
host="localhost", port=5432, dbname="test",
|
||||
user="test", password="test", sslmode="disable",
|
||||
)
|
||||
repo = PostgresPortfolioRepository(config)
|
||||
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = lambda s: mock_cursor
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchall.return_value = d1_rows
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
repo._connection = mock_conn
|
||||
|
||||
# ensure_schema 무력화
|
||||
with patch.object(repo, "ensure_schema"):
|
||||
results = repo.list_broker_credentials("user-1", daemon_id="d1")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].daemon_id == "d1"
|
||||
assert results[0].broker == "kis"
|
||||
|
||||
|
||||
def test_list_broker_credentials_no_daemon_id_filter_returns_all() -> None:
|
||||
"""REQ-046 AC-6: daemon_id 미지정 시 전체 반환 (하위호환)."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig
|
||||
|
||||
rows = _make_rows_for_user("user-1")
|
||||
|
||||
config = PortfolioDBConfig(
|
||||
host="localhost", port=5432, dbname="test",
|
||||
user="test", password="test", sslmode="disable",
|
||||
)
|
||||
repo = PostgresPortfolioRepository(config)
|
||||
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = lambda s: mock_cursor
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchall.return_value = rows
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
repo._connection = mock_conn
|
||||
|
||||
with patch.object(repo, "ensure_schema"):
|
||||
results = repo.list_broker_credentials("user-1")
|
||||
|
||||
assert len(results) == 2
|
||||
Reference in New Issue
Block a user