"""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"