"""Tests for BAT-019: GET /v1/daemons — caller-scoped daemon list (REQ-045).""" from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from app.core.config import Settings, get_settings from app.domain.models import Daemon, TokenPrincipal from app.main import create_app # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" def _make_settings(**kwargs) -> Settings: return Settings( allowed_origins_raw="http://localhost:3000", **kwargs, ) def _make_app(settings: Settings) -> 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 = "test-token") -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def _make_daemon( daemon_id: str = "d-1", owner_user_id: str = "user-1", daemon_slug: str = "trex-orb-v1", display_name: str | None = "ORB v1", is_active: bool = True, ) -> Daemon: return Daemon( id=daemon_id, owner_user_id=owner_user_id, daemon_slug=daemon_slug, display_name=display_name, is_active=is_active, ) def _make_mock_repo_with_principal(principal: TokenPrincipal, daemons: list[Daemon]) -> MagicMock: mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.list_daemons_for_user.return_value = daemons return mock_repo _SETTINGS_DB = _make_settings(db_enabled=True, credential_encryption_key=_TEST_ENC_KEY) # --------------------------------------------------------------------------- # REQ-045 AC: 사용자A(데몬2) → 자기 2개만 # --------------------------------------------------------------------------- def test_daemons_returns_caller_owned_daemons() -> None: """REQ-045 AC: 사용자A 소유 2개 데몬 → 2개 반환.""" principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", ) daemons = [ _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True), _make_daemon("d-2", "user-1", "trex-tsl-v1", "TSL v1", is_active=True), ] mock_repo = _make_mock_repo_with_principal(principal, daemons) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/daemons", headers=_auth_header()) assert resp.status_code == 200 body = resp.json() assert body["total"] == 2 assert len(body["daemons"]) == 2 slugs = {d["daemon_slug"] for d in body["daemons"]} assert slugs == {"trex-orb-v1", "trex-tsl-v1"} # --------------------------------------------------------------------------- # REQ-045 AC: 필드 4개 포함 # --------------------------------------------------------------------------- def test_daemons_response_has_required_fields() -> None: """REQ-045 AC: id, daemon_slug, display_name, is_active 필드 포함.""" principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", ) daemon = _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True) mock_repo = _make_mock_repo_with_principal(principal, [daemon]) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/daemons", headers=_auth_header()) assert resp.status_code == 200 item = resp.json()["daemons"][0] assert item["id"] == "d-1" assert item["daemon_slug"] == "trex-orb-v1" assert item["display_name"] == "ORB v1" assert item["is_active"] is True # --------------------------------------------------------------------------- # REQ-045 AC: display_name None 허용 # --------------------------------------------------------------------------- def test_daemons_display_name_can_be_none() -> None: """REQ-045: display_name=None인 데몬도 정상 반환.""" principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", ) daemon = _make_daemon("d-3", "user-1", "trex-custom", None, is_active=True) mock_repo = _make_mock_repo_with_principal(principal, [daemon]) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/daemons", headers=_auth_header()) assert resp.status_code == 200 item = resp.json()["daemons"][0] assert item["display_name"] is None # --------------------------------------------------------------------------- # REQ-045 AC: 타사용자 데몬 비노출 (사용자B 데몬은 user-2 소유) # --------------------------------------------------------------------------- def test_daemons_does_not_expose_other_user_daemons() -> None: """REQ-045 AC: 사용자B 데몬은 사용자A 조회 시 반환되지 않는다.""" principal_a = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", ) # list_daemons_for_user("user-1")은 user-1 소유만 반환 (repository 레벨 격리) daemons_for_user_a = [ _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True), ] mock_repo = _make_mock_repo_with_principal(principal_a, daemons_for_user_a) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/daemons", headers=_auth_header()) assert resp.status_code == 200 body = resp.json() assert body["total"] == 1 # user-2 소유 데몬은 없음 owner_ids = {d.get("owner_user_id") for d in body["daemons"]} # DaemonResponse는 owner_user_id 미포함이므로, repository 호출 인수 검증 mock_repo.list_daemons_for_user.assert_called_once_with("user-1") # --------------------------------------------------------------------------- # REQ-045 AC: 데몬 없으면 빈 목록 200 # --------------------------------------------------------------------------- def test_daemons_empty_list_returns_200() -> None: """REQ-045 AC: 데몬 없는 사용자 → 빈 목록 200.""" principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", ) mock_repo = _make_mock_repo_with_principal(principal, []) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/daemons", headers=_auth_header()) assert resp.status_code == 200 body = resp.json() assert body["daemons"] == [] assert body["total"] == 0 # --------------------------------------------------------------------------- # REQ-045 AC: 무토큰 → 401 # --------------------------------------------------------------------------- def test_daemons_no_token_returns_401() -> None: """REQ-045 AC: 무토큰 → 401.""" settings = _make_settings(db_enabled=False) client = _make_app(settings) resp = client.get("/v1/daemons") assert resp.status_code == 401 # --------------------------------------------------------------------------- # REQ-045 AC: admin도 caller 스코프 (admin user_id=None → 빈 목록) # --------------------------------------------------------------------------- def test_daemons_admin_token_with_user_id_none_returns_empty() -> None: """REQ-045: env admin 토큰(user_id=None)은 빈 목록 200 반환.""" settings = _make_settings(db_enabled=False, admin_tokens_raw="admin-env-token") mock_repo = MagicMock() mock_repo.list_daemons_for_user.return_value = [] with patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(settings) resp = client.get("/v1/daemons", headers=_auth_header("admin-env-token")) assert resp.status_code == 200 body = resp.json() assert body["daemons"] == [] assert body["total"] == 0 # user_id=None이므로 list_daemons_for_user는 호출되지 않아야 함 mock_repo.list_daemons_for_user.assert_not_called() def test_daemons_admin_token_with_user_id_returns_caller_daemons() -> None: """REQ-045: admin DB 토큰(user_id='admin-1')은 admin-1 소유 데몬만 반환.""" principal = TokenPrincipal( token_id="t-admin", user_id="admin-1", permission="admin", token_type="admin", runtime_id="rt-admin", ) daemons = [ _make_daemon("d-admin-1", "admin-1", "admin-bot", "Admin Bot", is_active=True), ] mock_repo = _make_mock_repo_with_principal(principal, daemons) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/daemons", headers=_auth_header()) assert resp.status_code == 200 body = resp.json() assert body["total"] == 1 assert body["daemons"][0]["id"] == "d-admin-1" mock_repo.list_daemons_for_user.assert_called_once_with("admin-1") # --------------------------------------------------------------------------- # REQ-045: is_active=False인 데몬도 반환 (소유 데몬 전체) # --------------------------------------------------------------------------- def test_daemons_includes_inactive_daemons() -> None: """REQ-045: inactive 데몬도 is_active=False로 반환됨.""" principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", ) daemons = [ _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True), _make_daemon("d-2", "user-1", "old-bot", "Old Bot", is_active=False), ] mock_repo = _make_mock_repo_with_principal(principal, daemons) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/daemons", headers=_auth_header()) assert resp.status_code == 200 body = resp.json() assert body["total"] == 2 active_flags = {d["daemon_slug"]: d["is_active"] for d in body["daemons"]} assert active_flags["trex-orb-v1"] is True assert active_flags["old-bot"] is False # --------------------------------------------------------------------------- # REQ-044 회귀: GET /v1/assets read 토큰 → 200, 쓰기는 admin 필요 → 403 # --------------------------------------------------------------------------- def test_assets_read_token_200_regression() -> None: """REQ-044 회귀: read 토큰으로 GET /v1/assets → 200.""" principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.list_assets.return_value = [] mock_repo.get_tags_by_ids.return_value = {} with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/assets", headers=_auth_header()) assert resp.status_code == 200 assert resp.json()["total"] == 0 def test_assets_write_requires_admin_regression() -> None: """REQ-044 회귀: read 토큰으로 POST /v1/assets → 403 (쓰기는 require_admin 유지).""" principal = TokenPrincipal( token_id="t1", user_id="user-1", permission="read", token_type="service", runtime_id="rt-1", ) 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): client = _make_app(_SETTINGS_DB) resp = client.post( "/v1/assets", json={"symbol": "005930", "markets": ["KRX"]}, headers=_auth_header(), ) assert resp.status_code == 403 def test_assets_no_token_returns_401_regression() -> None: """REQ-044 회귀: 무토큰 GET /v1/assets → 401.""" settings = _make_settings(db_enabled=False) client = _make_app(settings) resp = client.get("/v1/assets") assert resp.status_code == 401 def test_assets_admin_get_200_regression() -> None: """REQ-044 회귀: admin 토큰 GET /v1/assets → 200 (기존 동작 유지).""" principal = TokenPrincipal( token_id="t-admin", user_id="admin-1", permission="admin", token_type="admin", runtime_id="rt-1", ) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.list_assets.return_value = [] mock_repo.get_tags_by_ids.return_value = {} with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_SETTINGS_DB) resp = client.get("/v1/assets", headers=_auth_header()) assert resp.status_code == 200