"""BAT-022: REQ-054 — 인증정보 관리 보안 격리 (write-only, secret 미반환, 로그 미노출) + REQ-051 회귀.""" 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.config import Settings, get_settings from app.core.encryption import decrypt_payload, encrypt_payload from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User from app.main import create_app _TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" DAEMON_ID = "daemon-001" OWNER_USER_ID = "user-owner" BROKER = "kis" ACCOUNT_REF = "acc-001" APP_KEY = "real-app-key-secret" APP_SECRET = "real-app-secret-value" 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 _auth_header(token: str = "user-token") -> dict: return {"Authorization": f"Bearer {token}"} def _make_daemon(owner_user_id: str = OWNER_USER_ID) -> Daemon: return Daemon( id=DAEMON_ID, owner_user_id=owner_user_id, daemon_slug="trex-orb", display_name="ORB", is_active=True, ) def _make_cred( account_ref: str = ACCOUNT_REF, credential_version: int = 1, app_key: str = APP_KEY, app_secret: str = APP_SECRET, ) -> BrokerCredential: payload_json = json.dumps({"app_key": app_key, "app_secret": app_secret}) encrypted = encrypt_payload(_TEST_ENC_KEY, payload_json) return BrokerCredential( user_id=OWNER_USER_ID, broker=BROKER, account_ref=account_ref, encrypted_payload=encrypted, credential_version=credential_version, last_rotated_at=datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc), daemon_id=DAEMON_ID, ) def _make_user_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal: return TokenPrincipal( token_id="tok-1", user_id=user_id, permission="read", token_type="user", ) def _make_admin_principal(user_id: str = "admin-user") -> TokenPrincipal: return TokenPrincipal( token_id="adm-1", user_id=user_id, permission="admin", token_type="admin", ) def _make_service_principal(user_id: str = OWNER_USER_ID, daemon_id: str = DAEMON_ID) -> TokenPrincipal: return TokenPrincipal( token_id="svc-1", user_id=user_id, permission="read", token_type="service", runtime_id="rt-1", daemon_id=daemon_id, ) # --------------------------------------------------------------------------- # REQ-054 AC-1: PUT 응답에 app_key/app_secret 없음 # --------------------------------------------------------------------------- def test_put_credential_response_has_no_secret_fields() -> None: """REQ-054 AC-1: PUT 응답 JSON에 app_key, app_secret 키 없음(최상위·중첩 포함).""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() cred = _make_cred() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon mock_repo.upsert_broker_credential.return_value = cred with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.put( f"/v1/daemons/{DAEMON_ID}/credential", json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER}, headers=_auth_header(), ) assert resp.status_code == 200 body = resp.json() # Recursively check no secret fields anywhere in response body_str = json.dumps(body) assert "app_key" not in body_str assert "app_secret" not in body_str assert "encrypted_payload" not in body_str # --------------------------------------------------------------------------- # REQ-054 AC-2: GET 응답에 app_key/app_secret 없음 # --------------------------------------------------------------------------- def test_get_credential_meta_response_has_no_secret_fields() -> None: """REQ-054 AC-2: GET 응답 JSON에 app_key, app_secret 키 없음.""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() cred = _make_cred() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon mock_repo.get_broker_credential_by_daemon.return_value = cred with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header()) assert resp.status_code == 200 body_str = json.dumps(resp.json()) assert "app_key" not in body_str assert "app_secret" not in body_str assert "encrypted_payload" not in body_str # --------------------------------------------------------------------------- # REQ-054 AC-3: admin CRUD 응답에 app_key/app_secret/credential_payload 없음 # --------------------------------------------------------------------------- def test_admin_create_response_has_no_credential_fields() -> None: """REQ-054 AC-3: POST /v1/admin/daemons 응답에 app_key/app_secret/credential_payload 없음.""" settings = _make_settings() from app.domain.models import Daemon principal = _make_admin_principal() daemon = Daemon( id=DAEMON_ID, owner_user_id=OWNER_USER_ID, daemon_slug="trex-orb", display_name="ORB", is_active=True, ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.create_daemon.return_value = daemon with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.post( "/v1/admin/daemons", json={"daemon_slug": "trex-orb", "owner_user_id": OWNER_USER_ID}, headers=_auth_header("admin-token"), ) assert resp.status_code == 201 body_str = json.dumps(resp.json()) assert "app_key" not in body_str assert "app_secret" not in body_str assert "credential_payload" not in body_str def test_admin_patch_response_has_no_credential_fields() -> None: """REQ-054 AC-3: PATCH /v1/admin/daemons/{id} 응답에 app_key/app_secret/credential_payload 없음.""" settings = _make_settings() from app.domain.models import Daemon principal = _make_admin_principal() daemon = Daemon( id=DAEMON_ID, owner_user_id=OWNER_USER_ID, daemon_slug="trex-orb", display_name="NEW", is_active=True, ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon mock_repo.update_daemon.return_value = daemon with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.patch( f"/v1/admin/daemons/{DAEMON_ID}", json={"display_name": "NEW"}, headers=_auth_header("admin-token"), ) assert resp.status_code == 200 body_str = json.dumps(resp.json()) assert "app_key" not in body_str assert "app_secret" not in body_str assert "credential_payload" not in body_str # --------------------------------------------------------------------------- # REQ-054 AC-4: caplog — PUT 처리 중 로그에 app_key/app_secret 값 미포함 # --------------------------------------------------------------------------- def test_put_credential_log_does_not_expose_secret(caplog) -> None: """REQ-054 AC-4: PUT 처리 중 로그에 실제 app_key/app_secret 값 미포함.""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() cred = _make_cred(app_key=APP_KEY, app_secret=APP_SECRET) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon mock_repo.upsert_broker_credential.return_value = cred with caplog.at_level(logging.DEBUG, logger="app.api.v1_daemon_credential"), \ patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) client.put( f"/v1/daemons/{DAEMON_ID}/credential", json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER}, headers=_auth_header(), ) log_text = " ".join(caplog.messages) # Actual values must not appear in logs assert APP_KEY not in log_text, f"app_key found in logs: {log_text}" assert APP_SECRET not in log_text, f"app_secret found in logs: {log_text}" # --------------------------------------------------------------------------- # REQ-054 AC-5: DB に암호화 저장 확인 (encrypted_payload가 gAAAA... 형태인지 upsert 호출 인수로 검증) # --------------------------------------------------------------------------- def test_put_credential_stores_encrypted_payload() -> None: """REQ-054 AC-5: PUT 시 Fernet 암호화된 payload를 DB에 저장함 확인.""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() cred = _make_cred() captured_encrypted = {} def mock_upsert(user_id, broker, *, account_ref, encrypted_payload, daemon_id=None): captured_encrypted["payload"] = encrypted_payload return cred mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon mock_repo.upsert_broker_credential.side_effect = mock_upsert with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.put( f"/v1/daemons/{DAEMON_ID}/credential", json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER}, headers=_auth_header(), ) assert resp.status_code == 200 # The stored payload must be a Fernet token (starts with gAAAA) stored = captured_encrypted["payload"] assert stored.startswith("gAAAA"), f"Not a Fernet token: {stored[:20]}" # Must not be plaintext assert APP_KEY not in stored assert APP_SECRET not in stored # Verify it actually decrypts correctly decrypted = json.loads(decrypt_payload(_TEST_ENC_KEY, stored)) assert decrypted["app_key"] == APP_KEY assert decrypted["app_secret"] == APP_SECRET # --------------------------------------------------------------------------- # REQ-054 AC-6: PUT 이후 service-token GET /v1/user_info → correct account_ref returned # (유일 decrypt 경로 불변 확인) # --------------------------------------------------------------------------- def test_put_then_service_token_user_info_returns_correct_account_ref() -> None: """REQ-054 AC-6: PUT 후 service-token /v1/user_info에서 동일 account_ref 반환 (decrypt 경로 불변).""" settings = _make_settings() # 1. PUT credential user_principal = _make_user_principal() daemon = _make_daemon() cred_stored = _make_cred(account_ref="my-account-789", app_key=APP_KEY, app_secret=APP_SECRET) mock_repo_put = MagicMock() mock_repo_put.get_token_principal.return_value = user_principal mock_repo_put.get_daemon_by_id.return_value = daemon mock_repo_put.upsert_broker_credential.return_value = cred_stored with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_put), \ patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo_put): client_put = _make_client(settings, mock_repo_put) resp_put = client_put.put( f"/v1/daemons/{DAEMON_ID}/credential", json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account-789", "broker": BROKER}, headers=_auth_header("user-token"), ) assert resp_put.status_code == 200 # 2. GET /v1/user_info via service-token svc_principal = _make_service_principal() user = User(id=OWNER_USER_ID, username="alice", permission="read", is_active=True) mock_repo_svc = MagicMock() mock_repo_svc.get_token_principal.return_value = svc_principal mock_repo_svc.get_user.return_value = user mock_repo_svc.list_broker_credentials.return_value = [cred_stored] with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_svc), \ patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_svc): client_svc = _make_client(settings, mock_repo_svc) resp_svc = client_svc.get("/v1/user_info", headers={"Authorization": "Bearer svc-token"}) assert resp_svc.status_code == 200 body = resp_svc.json() assert body["broker"] is not None assert body["broker"]["account_ref"] == "my-account-789" # credential_payload must be decrypted and returned on service-token path assert body["broker"]["credential_payload"] is not None assert body["broker"]["credential_payload"]["app_key"] == APP_KEY assert body["broker"]["credential_payload"]["app_secret"] == APP_SECRET # --------------------------------------------------------------------------- # REQ-054 AC-7: REQ-051 회귀 — 신규 API 추가 후 기존 service-token 보안 격리 PASS # --------------------------------------------------------------------------- def test_req051_regression_web_token_user_info_still_403() -> None: """REQ-054 AC-7 / REQ-051 AC-1 회귀: web token → /v1/user_info → 403 (불변).""" settings = _make_settings() principal = TokenPrincipal( token_id="t1", user_id=OWNER_USER_ID, permission="read", token_type="user", daemon_id=DAEMON_ID, ) 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={"Authorization": "Bearer web-token"}) assert resp.status_code == 403 def test_req051_regression_service_token_without_daemon_id_403() -> None: """REQ-054 AC-7 / REQ-051 AC-3 회귀: service-token without daemon_id → 403.""" settings = _make_settings() principal = TokenPrincipal( token_id="t1", user_id=OWNER_USER_ID, 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={"Authorization": "Bearer svc-no-daemon"}) assert resp.status_code == 403 def test_req051_regression_unauthenticated_401() -> None: """REQ-054 AC-7 / REQ-051 AC-2 회귀: 비인증 → 401.""" settings = _make_settings() client = _make_client(settings, MagicMock()) resp = client.get("/v1/user_info") assert resp.status_code == 401 def test_req051_regression_service_token_with_daemon_returns_broker() -> None: """REQ-054 AC-7 / REQ-051 AC-1 회귀: service-token + daemon_id → 200 + broker (불변).""" settings = _make_settings() svc_principal = _make_service_principal() user = User(id=OWNER_USER_ID, username="alice", permission="read", is_active=True) cred = _make_cred() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = svc_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={"Authorization": "Bearer svc-token"}) assert resp.status_code == 200 body = resp.json() assert body["broker"] is not None assert body["broker"]["account_ref"] == ACCOUNT_REF assert body["broker"]["credential_payload"]["app_key"] == APP_KEY assert body["broker"]["credential_payload"]["app_secret"] == APP_SECRET def test_req051_regression_other_endpoints_no_credential_exposure() -> None: """REQ-054 AC-7 / REQ-051 AC-5 회귀: /health 등에 credential 필드 없음.""" settings = _make_settings() client = TestClient(create_app(settings_factory=lambda: settings)) resp = client.get("/health") assert resp.status_code == 200 body = resp.json() assert "app_key" not in body assert "app_secret" not in body assert "credential_payload" not in body assert "encrypted_payload" not in body