"""BAT-022: REQ-052 — GET/PUT/DELETE /v1/daemons/{daemon_id}/credential (소유 스코프 인증정보 관리).""" from __future__ import annotations import json 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, Daemon, TokenPrincipal, User from app.main import create_app _TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" OWNER_USER_ID = "user-owner" OTHER_USER_ID = "user-other" DAEMON_ID = "daemon-001" BROKER = "kis" ACCOUNT_REF = "acc-001" APP_KEY = "my-app-key-12345" APP_SECRET = "my-app-secret-xyz" 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( daemon_id: str = DAEMON_ID, owner_user_id: str = OWNER_USER_ID, is_active: bool = True, ) -> Daemon: return Daemon( id=daemon_id, owner_user_id=owner_user_id, daemon_slug="trex-orb", display_name="ORB", is_active=is_active, ) def _make_cred( daemon_id: str = DAEMON_ID, account_ref: str = ACCOUNT_REF, credential_version: int = 1, ) -> 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", runtime_id=None, daemon_id=None, ) def _make_service_principal(user_id: str = OWNER_USER_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, ) 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", ) # --------------------------------------------------------------------------- # REQ-052 AC-1: PUT → 200 + 메타데이터 (secret 미포함) # --------------------------------------------------------------------------- def test_put_credential_returns_meta_without_secret() -> None: """REQ-052 AC-1: user-token(소유자) PUT → 200 + 메타데이터, app_key/app_secret 없음.""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() cred = _make_cred(credential_version=1) 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() # Metadata fields present assert body["daemon_id"] == DAEMON_ID assert body["broker"] == BROKER assert body["account_ref"] == ACCOUNT_REF assert body["has_credential"] is True assert body["credential_version"] == 1 # Secret fields MUST NOT be in response (REQ-052 AC-1, REQ-054 AC-1) assert "app_key" not in body assert "app_secret" not in body assert "encrypted_payload" not in body # --------------------------------------------------------------------------- # REQ-052 AC-2: 회전 시 credential_version 증가 # --------------------------------------------------------------------------- def test_put_credential_rotation_increments_version() -> None: """REQ-052 AC-2: 동일 daemon 재PUT → credential_version 이전보다 1 증가.""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() # Simulate rotation: upsert returns version=2 cred_v2 = _make_cred(credential_version=2) 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_v2 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 assert resp.json()["credential_version"] == 2 # upsert called with correct daemon_id mock_repo.upsert_broker_credential.assert_called_once() call_kwargs = mock_repo.upsert_broker_credential.call_args assert call_kwargs.kwargs["daemon_id"] == DAEMON_ID # --------------------------------------------------------------------------- # REQ-052 AC-3: service-token /v1/user_info 정합 (CLI·API·service-token 경로 통일) # AC-3 is integration-level: verified by REQ-054 AC-6 (service-token GET /v1/user_info after PUT) # Here we verify upsert is called with the correct daemon_id + account_ref # --------------------------------------------------------------------------- def test_put_credential_upsert_called_with_correct_daemon_id() -> None: """REQ-052 AC-3 partial: PUT 이후 upsert에 daemon_id가 정확히 전달됨.""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() cred = _make_cred(account_ref="my-account") 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) client.put( f"/v1/daemons/{DAEMON_ID}/credential", json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account", "broker": BROKER}, headers=_auth_header(), ) mock_repo.upsert_broker_credential.assert_called_once() kwargs = mock_repo.upsert_broker_credential.call_args.kwargs assert kwargs["daemon_id"] == DAEMON_ID assert kwargs["account_ref"] == "my-account" # --------------------------------------------------------------------------- # REQ-052 AC-4: GET → 200 + 메타데이터 (secret 미포함) # --------------------------------------------------------------------------- def test_get_credential_meta_returns_metadata_without_secret() -> None: """REQ-052 AC-4: user-token(소유자) GET → 200 + 메타데이터, 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 = resp.json() assert body["broker"] == BROKER assert body["account_ref"] == ACCOUNT_REF assert body["has_credential"] is True assert body["credential_version"] == 1 assert "app_key" not in body assert "app_secret" not in body assert "encrypted_payload" not in body # --------------------------------------------------------------------------- # REQ-052 AC-5: DELETE → 204, 이후 GET has_credential=False # --------------------------------------------------------------------------- def test_delete_credential_returns_204() -> None: """REQ-052 AC-5: DELETE → 204.""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon mock_repo.delete_broker_credential.return_value = True 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.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header()) assert resp.status_code == 204 def test_get_after_delete_returns_has_credential_false() -> None: """REQ-052 AC-5: DELETE 이후 GET → has_credential=False.""" settings = _make_settings() principal = _make_user_principal() daemon = _make_daemon() 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 = None # deleted 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 assert resp.json()["has_credential"] is False # --------------------------------------------------------------------------- # REQ-052 AC-6: 타 사용자 토큰 → 403 (소유 스코프 음성) # --------------------------------------------------------------------------- def test_put_credential_other_user_returns_403() -> None: """REQ-052 AC-6: 다른 user의 토큰으로 PUT → 403.""" settings = _make_settings() # OTHER user's principal principal = _make_user_principal(user_id=OTHER_USER_ID) daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon 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": "k", "app_secret": "s", "account_ref": "ref", "broker": BROKER}, headers=_auth_header("other-token"), ) assert resp.status_code == 403 def test_get_credential_other_user_returns_403() -> None: """REQ-052 AC-6: 다른 user의 토큰으로 GET → 403.""" settings = _make_settings() principal = _make_user_principal(user_id=OTHER_USER_ID) daemon = _make_daemon(owner_user_id=OWNER_USER_ID) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon 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("other-token")) assert resp.status_code == 403 def test_delete_credential_other_user_returns_403() -> None: """REQ-052 AC-6: 다른 user의 토큰으로 DELETE → 403.""" settings = _make_settings() principal = _make_user_principal(user_id=OTHER_USER_ID) daemon = _make_daemon(owner_user_id=OWNER_USER_ID) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon 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.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token")) assert resp.status_code == 403 # --------------------------------------------------------------------------- # REQ-052 AC-7: 비인증 → 401 # --------------------------------------------------------------------------- def test_credential_no_token_returns_401() -> None: """REQ-052 AC-7: 인증 토큰 없이 호출 → 401.""" settings = _make_settings() client = _make_client(settings, MagicMock()) resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential") assert resp.status_code == 401 resp2 = client.put( f"/v1/daemons/{DAEMON_ID}/credential", json={"app_key": "k", "app_secret": "s", "account_ref": "ref"}, ) assert resp2.status_code == 401 resp3 = client.delete(f"/v1/daemons/{DAEMON_ID}/credential") assert resp3.status_code == 401 # --------------------------------------------------------------------------- # REQ-052 AC-8: 미존재 daemon_id → 404 # --------------------------------------------------------------------------- def test_credential_nonexistent_daemon_returns_404() -> None: """REQ-052 AC-8: 존재하지 않는 daemon_id → 404.""" settings = _make_settings() principal = _make_user_principal() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = None # not found 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/nonexistent/credential", headers=_auth_header()) assert resp.status_code == 404 def test_put_nonexistent_daemon_returns_404() -> None: """REQ-052 AC-8: PUT 미존재 daemon → 404.""" settings = _make_settings() principal = _make_user_principal() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = None 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( "/v1/daemons/nonexistent/credential", json={"app_key": "k", "app_secret": "s", "account_ref": "ref"}, headers=_auth_header(), ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # REQ-052 AC-9: admin 토큰으로 타인 daemon 인증정보 write → 403 # --------------------------------------------------------------------------- def test_admin_cannot_write_other_user_credential() -> None: """REQ-052 AC-9: admin role이라도 타인 소유 daemon write → 403.""" settings = _make_settings() admin_principal = _make_admin_principal(user_id="admin-user") daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER, not admin mock_repo = MagicMock() mock_repo.get_token_principal.return_value = admin_principal mock_repo.get_daemon_by_id.return_value = daemon 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": "k", "app_secret": "s", "account_ref": "ref"}, headers=_auth_header("admin-token"), ) assert resp.status_code == 403 def test_admin_cannot_read_other_user_credential() -> None: """REQ-052 AC-9: admin role이라도 타인 소유 daemon GET → 403.""" settings = _make_settings() admin_principal = _make_admin_principal(user_id="admin-user") daemon = _make_daemon(owner_user_id=OWNER_USER_ID) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = admin_principal mock_repo.get_daemon_by_id.return_value = daemon 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("admin-token")) assert resp.status_code == 403 # --------------------------------------------------------------------------- # REQ-052 AC-10: service-token → 403 # --------------------------------------------------------------------------- def test_service_token_credential_get_returns_403() -> None: """REQ-052 AC-10: service-token으로 GET /v1/daemons/{id}/credential → 403.""" settings = _make_settings() svc_principal = _make_service_principal() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = svc_principal 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("svc-token")) assert resp.status_code == 403 def test_service_token_credential_put_returns_403() -> None: """REQ-052 AC-10: service-token으로 PUT → 403.""" settings = _make_settings() svc_principal = _make_service_principal() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = svc_principal 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": "k", "app_secret": "s", "account_ref": "ref"}, headers=_auth_header("svc-token"), ) assert resp.status_code == 403 def test_service_token_credential_delete_returns_403() -> None: """REQ-052 AC-10: service-token으로 DELETE → 403.""" settings = _make_settings() svc_principal = _make_service_principal() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = svc_principal 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.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token")) assert resp.status_code == 403