"""BAT-022: REQ-053 — POST/PATCH /v1/admin/daemons (admin 데몬 CRUD).""" from __future__ import annotations from datetime import datetime, timezone from unittest.mock import MagicMock, call, patch import pytest from fastapi.testclient import TestClient from app.core.config import Settings, get_settings from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User from app.main import create_app _TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" ADMIN_USER_ID = "admin-user" OWNER_A_ID = "user-a" OWNER_B_ID = "user-b" DAEMON_ID = "daemon-001" 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 = "admin-token") -> dict: return {"Authorization": f"Bearer {token}"} def _make_daemon( daemon_id: str = DAEMON_ID, owner_user_id: str = OWNER_A_ID, daemon_slug: str = "trex-orb", display_name: str = "ORB", 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, created_at=datetime(2026, 6, 28, 10, 0, 0, tzinfo=timezone.utc), updated_at=datetime(2026, 6, 28, 10, 0, 0, tzinfo=timezone.utc), ) def _make_admin_principal(user_id: str = ADMIN_USER_ID) -> TokenPrincipal: return TokenPrincipal( token_id="adm-1", user_id=user_id, permission="admin", token_type="admin", ) def _make_user_principal(user_id: str = OWNER_A_ID) -> TokenPrincipal: return TokenPrincipal( token_id="usr-1", user_id=user_id, permission="read", token_type="user", ) def _make_service_principal(user_id: str = OWNER_A_ID) -> TokenPrincipal: return TokenPrincipal( token_id="svc-1", user_id=user_id, permission="read", token_type="service", daemon_id=DAEMON_ID, ) # --------------------------------------------------------------------------- # REQ-053 AC-1: POST 생성 → 201 + DaemonAdminResponse # --------------------------------------------------------------------------- def test_admin_create_daemon_returns_201() -> None: """REQ-053 AC-1: admin POST → 201 + id/daemon_slug/display_name/owner_user_id/is_active.""" settings = _make_settings() principal = _make_admin_principal() daemon = _make_daemon() 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", "display_name": "ORB", "owner_user_id": OWNER_A_ID}, headers=_auth_header(), ) assert resp.status_code == 201 body = resp.json() assert body["id"] == DAEMON_ID assert body["daemon_slug"] == "trex-orb" assert body["display_name"] == "ORB" assert body["owner_user_id"] == OWNER_A_ID assert body["is_active"] is True # Secret fields must not appear (REQ-054 AC-3) assert "app_key" not in body assert "app_secret" not in body assert "credential_payload" not in body # --------------------------------------------------------------------------- # REQ-053 AC-2: 중복 slug → 409 # --------------------------------------------------------------------------- def test_admin_create_daemon_duplicate_slug_returns_409() -> None: """REQ-053 AC-2: 중복 daemon_slug → 409 Conflict.""" settings = _make_settings() principal = _make_admin_principal() mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.create_daemon.side_effect = ValueError("이미 존재하는 daemon slug입니다: trex-orb") 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_A_ID}, headers=_auth_header(), ) assert resp.status_code == 409 # --------------------------------------------------------------------------- # REQ-053 AC-3: display_name 변경 — BrokerCredential 유지 # --------------------------------------------------------------------------- def test_admin_patch_display_name_credential_preserved() -> None: """REQ-053 AC-3: display_name 변경 → 200, BrokerCredential 삭제 없음.""" settings = _make_settings() principal = _make_admin_principal() daemon_before = _make_daemon(display_name="OLD NAME") daemon_after = _make_daemon(display_name="NEW NAME") mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon_before mock_repo.update_daemon.return_value = daemon_after 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 NAME"}, headers=_auth_header(), ) assert resp.status_code == 200 assert resp.json()["display_name"] == "NEW NAME" # update_daemon_owner must NOT be called (no owner change) mock_repo.update_daemon_owner.assert_not_called() # delete_credentials_by_daemon_id must NOT be called mock_repo.delete_credentials_by_daemon_id.assert_not_called() # --------------------------------------------------------------------------- # REQ-053 AC-4: owner 재할당 → BrokerCredential 자동 삭제 # --------------------------------------------------------------------------- def test_admin_patch_owner_invalidates_credentials() -> None: """REQ-053 AC-4: owner 재할당 → 200, BrokerCredential 자동 삭제(무효화).""" settings = _make_settings() principal = _make_admin_principal() daemon_before = _make_daemon(owner_user_id=OWNER_A_ID) daemon_after = _make_daemon(owner_user_id=OWNER_B_ID) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon_before mock_repo.update_daemon_owner.return_value = daemon_after 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={"owner_user_id": OWNER_B_ID}, headers=_auth_header(), ) assert resp.status_code == 200 assert resp.json()["owner_user_id"] == OWNER_B_ID # update_daemon_owner must be called (includes credential deletion) mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID) # --------------------------------------------------------------------------- # REQ-053 AC-5: owner 변경 후 이전 owner의 /v1/user_info → broker=null # (verified via update_daemon_owner deleting credentials; AC-4 covers the mechanism) # --------------------------------------------------------------------------- def test_admin_patch_owner_then_old_owner_loses_credential() -> None: """REQ-053 AC-5: owner 재할당 이후 이전 owner 인증정보 제거 확인 (update_daemon_owner 호출로 검증).""" settings = _make_settings() principal = _make_admin_principal() daemon_before = _make_daemon(owner_user_id=OWNER_A_ID) daemon_after = _make_daemon(owner_user_id=OWNER_B_ID) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon_before mock_repo.update_daemon_owner.return_value = daemon_after 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={"owner_user_id": OWNER_B_ID}, headers=_auth_header(), ) assert resp.status_code == 200 # update_daemon_owner deletes credentials as part of the operation (see postgres.py implementation) mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID) # --------------------------------------------------------------------------- # REQ-053 AC-6: 비활성화 → is_active=False + service token auto-revoke # --------------------------------------------------------------------------- def test_admin_deactivate_daemon_revokes_tokens() -> None: """REQ-053 AC-6: 비활성화 → 200 + is_active=False, service token auto-revoke.""" settings = _make_settings() principal = _make_admin_principal() daemon_active = _make_daemon(is_active=True) daemon_inactive = _make_daemon(is_active=False) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon_active mock_repo.update_daemon.return_value = daemon_inactive mock_repo.revoke_tokens_by_daemon_id.return_value = 1 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(f"/v1/admin/daemons/{DAEMON_ID}/deactivate", headers=_auth_header()) assert resp.status_code == 200 assert resp.json()["is_active"] is False mock_repo.update_daemon.assert_called_once_with(DAEMON_ID, is_active=False) mock_repo.revoke_tokens_by_daemon_id.assert_called_once_with(DAEMON_ID) # --------------------------------------------------------------------------- # REQ-053 AC-7: 재활성화 → is_active=True # --------------------------------------------------------------------------- def test_admin_activate_daemon_returns_active() -> None: """REQ-053 AC-7: 재활성화 → 200 + is_active=True.""" settings = _make_settings() principal = _make_admin_principal() daemon_inactive = _make_daemon(is_active=False) daemon_active = _make_daemon(is_active=True) mock_repo = MagicMock() mock_repo.get_token_principal.return_value = principal mock_repo.get_daemon_by_id.return_value = daemon_inactive mock_repo.update_daemon.return_value = daemon_active 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(f"/v1/admin/daemons/{DAEMON_ID}/activate", headers=_auth_header()) assert resp.status_code == 200 assert resp.json()["is_active"] is True # --------------------------------------------------------------------------- # REQ-053 AC-8: DELETE → 405 (하드 삭제 미제공) # --------------------------------------------------------------------------- def test_admin_delete_daemon_returns_405() -> None: """REQ-053 AC-8: DELETE /v1/admin/daemons/{id} → 405 Method Not Allowed.""" settings = _make_settings() principal = _make_admin_principal() 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_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.delete(f"/v1/admin/daemons/{DAEMON_ID}", headers=_auth_header()) assert resp.status_code == 405 # --------------------------------------------------------------------------- # REQ-053 AC-9: user-token(비admin) → 403 # --------------------------------------------------------------------------- def test_non_admin_user_token_create_daemon_returns_403() -> None: """REQ-053 AC-9: user-token(non-admin) → POST /v1/admin/daemons → 403.""" settings = _make_settings() principal = _make_user_principal() # permission=read 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_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": "test", "owner_user_id": OWNER_A_ID}, headers=_auth_header("user-token"), ) assert resp.status_code == 403 def test_non_admin_patch_returns_403() -> None: """REQ-053 AC-9: user-token → PATCH /v1/admin/daemons/{id} → 403.""" settings = _make_settings() principal = _make_user_principal() 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_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": "X"}, headers=_auth_header("user-token"), ) assert resp.status_code == 403 def test_non_admin_deactivate_returns_403() -> None: """REQ-053 AC-9: user-token → POST /v1/admin/daemons/{id}/deactivate → 403.""" settings = _make_settings() principal = _make_user_principal() 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_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.post( f"/v1/admin/daemons/{DAEMON_ID}/deactivate", headers=_auth_header("user-token"), ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # REQ-053 AC-10: service-token → 403 # --------------------------------------------------------------------------- def test_service_token_admin_crud_returns_403() -> None: """REQ-053 AC-10: service-token → POST /v1/admin/daemons → 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_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": "test", "owner_user_id": OWNER_A_ID}, headers=_auth_header("svc-token"), ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # REQ-053 AC-11: 비인증 → 401 # --------------------------------------------------------------------------- def test_admin_crud_no_token_returns_401() -> None: """REQ-053 AC-11: 비인증 → POST /v1/admin/daemons → 401.""" settings = _make_settings() client = _make_client(settings, MagicMock()) resp = client.post("/v1/admin/daemons", json={"daemon_slug": "test", "owner_user_id": "u1"}) assert resp.status_code == 401 # --------------------------------------------------------------------------- # REQ-053 AC-12: 미존재 daemon_id → 404 # --------------------------------------------------------------------------- def test_admin_patch_nonexistent_daemon_returns_404() -> None: """REQ-053 AC-12: PATCH 미존재 daemon → 404.""" settings = _make_settings() principal = _make_admin_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_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.patch( "/v1/admin/daemons/nonexistent", json={"display_name": "X"}, headers=_auth_header(), ) assert resp.status_code == 404 def test_admin_deactivate_nonexistent_daemon_returns_404() -> None: """REQ-053 AC-12: deactivate 미존재 daemon → 404.""" settings = _make_settings() principal = _make_admin_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_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_client(settings, mock_repo) resp = client.post( "/v1/admin/daemons/nonexistent/deactivate", headers=_auth_header(), ) assert resp.status_code == 404