Files
portfolio-hub/tests/test_bat020_user_info_daemon_scoped.py
genichinandClaude Sonnet 4.6 51b7cbee35 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
2026-06-28 00:45:46 +09:00

437 lines
18 KiB
Python

"""BAT-020: REQ-049, REQ-051 — GET /v1/user_info daemon-scoped + 보안 격리 테스트."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.auth import AuthContext
from app.core.config import Settings, get_settings
from app.core.encryption import encrypt_payload
from app.domain.models import BrokerCredential, TokenPrincipal, User
from app.main import create_app
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_TEST_ENC_KEY,
**kwargs,
)
def _make_client(settings: Settings, mock_repo) -> TestClient:
get_settings.cache_clear()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _svc_token(token: str = "svc-token") -> dict:
return {"Authorization": f"Bearer {token}"}
def _make_cred(user_id: str, daemon_id: str, broker: str = "kis", account_ref: str = "ref") -> BrokerCredential:
plain = json.dumps({"app_key": "k", "app_secret": "s"})
encrypted = encrypt_payload(_TEST_ENC_KEY, plain)
return BrokerCredential(
user_id=user_id, broker=broker, account_ref=account_ref,
encrypted_payload=encrypted, credential_version=1,
daemon_id=daemon_id,
)
# ---------------------------------------------------------------------------
# REQ-049 AC-1: daemon_id 있음 + broker 있음 → 200 + 단일 broker
# ---------------------------------------------------------------------------
def test_user_info_daemon_scoped_broker_returned() -> None:
"""REQ-049 AC-1: daemon_id 바인딩 token + 해당 daemon broker 있음 → 단일 BrokerItem."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
cred = _make_cred("u1", "d1", account_ref="acc-d1")
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = [cred]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 200
body = resp.json()
assert body["broker"] is not None
assert body["broker"]["account_ref"] == "acc-d1"
assert body["broker"]["broker"] == "kis"
assert body["broker"]["credential_payload"] == {"app_key": "k", "app_secret": "s"}
# REQ-049 AC-6: brokers 키 없음
assert "brokers" not in body
# ---------------------------------------------------------------------------
# REQ-049 AC-2: daemon_id 있음 + broker 없음 → 200 + broker=null
# ---------------------------------------------------------------------------
def test_user_info_daemon_no_broker_returns_null() -> None:
"""REQ-049 AC-2: daemon_id 있음, 해당 daemon 인증정보 없음 → broker=null."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = [] # 인증정보 없음
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 200
assert resp.json()["broker"] is None
# ---------------------------------------------------------------------------
# REQ-049 AC-3, REQ-051 AC-3: daemon_id 없는 service token → 403
# ---------------------------------------------------------------------------
def test_user_info_no_daemon_id_returns_403() -> None:
"""REQ-049 AC-3, REQ-051 AC-3: daemon_id=None service token → 403."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id=None,
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-049 AC-4/5: 같은 user 2데몬 격리
# ---------------------------------------------------------------------------
def test_user_info_daemon_isolation_two_daemons() -> None:
"""REQ-049 AC-4/5: daemon A, B가 각각 다른 account_ref를 반환."""
settings = _make_settings()
user = User(id="u1", username="alice", permission="read", is_active=True)
cred_a = _make_cred("u1", "d-a", account_ref="acc-A")
cred_b = _make_cred("u1", "d-b", account_ref="acc-B")
principal_a = TokenPrincipal(
token_id="ta", user_id="u1", permission="read",
token_type="service", daemon_id="d-a",
)
principal_b = TokenPrincipal(
token_id="tb", user_id="u1", permission="read",
token_type="service", daemon_id="d-b",
)
# daemon A 호출
mock_repo_a = MagicMock()
mock_repo_a.get_token_principal.return_value = principal_a
mock_repo_a.get_user.return_value = user
mock_repo_a.list_broker_credentials.return_value = [cred_a]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_a), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_a):
client = _make_client(settings, mock_repo_a)
resp_a = client.get("/v1/user_info", headers=_svc_token())
assert resp_a.status_code == 200
assert resp_a.json()["broker"]["account_ref"] == "acc-A"
# daemon B 호출
mock_repo_b = MagicMock()
mock_repo_b.get_token_principal.return_value = principal_b
mock_repo_b.get_user.return_value = user
mock_repo_b.list_broker_credentials.return_value = [cred_b]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_b), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_b):
client_b = _make_client(settings, mock_repo_b)
resp_b = client_b.get("/v1/user_info", headers=_svc_token())
assert resp_b.status_code == 200
assert resp_b.json()["broker"]["account_ref"] == "acc-B"
# 상호 반환 없음
assert resp_a.json()["broker"]["account_ref"] != resp_b.json()["broker"]["account_ref"]
# ---------------------------------------------------------------------------
# REQ-049 AC-6: 응답에 broker 단일 키 존재, brokers 리스트 없음
# ---------------------------------------------------------------------------
def test_user_info_response_has_broker_not_brokers() -> None:
"""REQ-049 AC-6: 응답 JSON에 broker 단일 키만 있고 brokers 키 없음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 200
body = resp.json()
assert "broker" in body
assert "brokers" not in body
# ---------------------------------------------------------------------------
# REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null
# ---------------------------------------------------------------------------
def test_user_info_decryption_failure_returns_null_payload() -> None:
"""REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null, 크래시 없음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
# 잘못된 encrypted_payload (복호화 실패)
cred = BrokerCredential(
user_id="u1", broker="kis", account_ref="ref",
encrypted_payload="INVALID_PAYLOAD",
credential_version=1, daemon_id="d1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = [cred]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 200
body = resp.json()
assert body["broker"] is not None
assert body["broker"]["account_ref"] == "ref"
assert body["broker"]["credential_payload"] is None # 복호화 실패
# ---------------------------------------------------------------------------
# REQ-051 AC-1: web 토큰 → 403
# ---------------------------------------------------------------------------
def test_user_info_web_token_returns_403() -> None:
"""REQ-051 AC-1: web user 토큰(token_type!=service) → 403."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="user", # service 아님
daemon_id="d1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-051 AC-2: 비인증 → 401
# ---------------------------------------------------------------------------
def test_user_info_unauthenticated_returns_401() -> None:
"""REQ-051 AC-2: Authorization 헤더 없음 → 401."""
settings = _make_settings()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = None # 토큰 없음
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info") # 헤더 없음
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# REQ-051 AC-4: 다른 daemon 인증정보 → null (데이터 격리)
# ---------------------------------------------------------------------------
def test_user_info_different_daemon_credential_not_returned() -> None:
"""REQ-051 AC-4: daemon_id="d1" token이 daemon_id="d2" 인증정보를 받지 않음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
# daemon_id="d1" 필터로 조회하면 빈 결과 (DB에는 d2만 있음)
mock_repo.list_broker_credentials.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 200
assert resp.json()["broker"] is None
# ---------------------------------------------------------------------------
# REQ-051 AC-5: 타 엔드포인트 응답에 credential_payload 미포함
# ---------------------------------------------------------------------------
def test_other_endpoints_do_not_expose_credential_payload(monkeypatch) -> None:
"""REQ-051 AC-5: GET /health, /v1/portfolios 등에 credential 필드 없음."""
settings = _make_settings()
client = TestClient(create_app(settings_factory=lambda: settings))
# /health 응답 검사
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert "credential_payload" not in body
assert "app_secret" not in body
assert "app_key" not in body
# ---------------------------------------------------------------------------
# REQ-051 AC-6: DB 오류 → broker=null graceful degrade (500 없음)
# ---------------------------------------------------------------------------
def test_user_info_db_error_returns_null_broker() -> None:
"""REQ-051 AC-6: list_broker_credentials DB 오류 → broker=null, 500 없음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.side_effect = RuntimeError("DB connection error")
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 200
assert resp.json()["broker"] is None
# ---------------------------------------------------------------------------
# REQ-051 AC-8: 복호화 실패 시 로그에 app_secret 미노출
# ---------------------------------------------------------------------------
def test_user_info_decryption_failure_log_does_not_expose_secret(caplog) -> None:
"""REQ-051 AC-8: 복호화 실패 로그에 app_key, app_secret 값 미포함."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", daemon_id="d1",
)
user = User(id="u1", username="alice", permission="read", is_active=True)
# 잘못된 encrypted_payload
cred = BrokerCredential(
user_id="u1", broker="kis", account_ref="ref",
encrypted_payload="INVALID_PAYLOAD_SECRET_VALUE",
credential_version=1, daemon_id="d1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = [cred]
with caplog.at_level(logging.WARNING, logger="app.api.v1_user_info"), \
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
client.get("/v1/user_info", headers=_svc_token())
# 로그에 app_secret, app_key 값이 없음 확인
log_text = " ".join(caplog.messages)
assert "mysecret" not in log_text
assert "mykey" not in log_text
# 로그에 INVALID_PAYLOAD 원문이 없음 (payload 노출 아님)
# 단지 오류 유형(class name)만 기록
assert "INVALID_PAYLOAD_SECRET_VALUE" not in log_text
# ---------------------------------------------------------------------------
# REQ-049 AC-9: 기존 필드들 회귀 없음
# ---------------------------------------------------------------------------
def test_user_info_regression_existing_fields_unchanged() -> None:
"""REQ-049 AC-9: broker 변경이 runtime_id, daemon_id, trading_enabled 등에 영향 없음."""
settings = _make_settings()
principal = TokenPrincipal(
token_id="t1", user_id="u1", permission="read",
token_type="service", runtime_id="rt-xyz", daemon_id="d1",
)
user = User(id="u1", username="alice", email="alice@example.com", permission="read", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user
mock_repo.list_broker_credentials.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get("/v1/user_info", headers=_svc_token())
assert resp.status_code == 200
body = resp.json()
assert body["runtime_id"] == "rt-xyz"
assert body["daemon_id"] == "d1"
assert body["trading_enabled"] is True
assert body["user_id"] == "u1"
assert body["username"] == "alice"
assert body["permission"] == "read"