- 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
437 lines
18 KiB
Python
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"
|