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:
2026-06-28 00:45:46 +09:00
co-authored by Claude Sonnet 4.6
parent fc99f507a5
commit 51b7cbee35
16 changed files with 1878 additions and 86 deletions
@@ -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