From 2dcbafb7a8552bcdd755d6f8ade917a9c96bb752 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 28 Jun 2026 18:20:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(dcy-011/bat-022):=20GET=20/v1/admin/daemons?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=E2=80=94=20UI=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=88=84=EB=9D=BD(405)=20=ED=95=B4=EC=86=8C=20(?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=86=B5=ED=95=A9=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B2=B0=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- app/api/v1_admin_daemons.py | 41 ++++++- app/repositories/postgres.py | 13 +++ app/schemas/v1.py | 19 ++++ tests/test_bat022_req053_admin_daemon_crud.py | 100 ++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) diff --git a/app/api/v1_admin_daemons.py b/app/api/v1_admin_daemons.py index e585405..74cb93a 100644 --- a/app/api/v1_admin_daemons.py +++ b/app/api/v1_admin_daemons.py @@ -9,7 +9,13 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status from app.core.auth import AuthContext, require_admin from app.core.config import Settings, get_settings from app.repositories.factory import get_portfolio_repository_from_settings -from app.schemas.v1 import DaemonAdminResponse, DaemonCreateRequest, DaemonUpdateRequest +from app.schemas.v1 import ( + DaemonAdminResponse, + DaemonCreateRequest, + DaemonDetail, + DaemonDetailListResponse, + DaemonUpdateRequest, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/admin", tags=["v1-admin"]) @@ -41,6 +47,39 @@ def _daemon_error_to_http(exc: ValueError) -> HTTPException: return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) +# --------------------------------------------------------------------------- +# GET /v1/admin/daemons — 전체 데몬 목록 (admin 전용) +# --------------------------------------------------------------------------- + +@router.get("/daemons", response_model=DaemonDetailListResponse) +def list_all_daemons( + _auth: AuthContext = Depends(require_admin), + settings: Settings = Depends(get_settings), +) -> DaemonDetailListResponse: + """Return all daemons across all owners (admin only). + + Matches trex-web DaemonDetailListResponse / DaemonDetail contract + consumed by adminListDaemons() (REQ-035). + """ + repo = _get_repo(settings) + try: + daemons = repo.list_daemons_all() + items = [ + DaemonDetail( + id=d.id, + daemon_slug=d.daemon_slug, + display_name=d.display_name, + is_active=d.is_active, + owner_user_id=d.owner_user_id, + ) + for d in daemons + ] + return DaemonDetailListResponse(daemons=items, total=len(items)) + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + + # --------------------------------------------------------------------------- # POST /v1/admin/daemons — 데몬 생성 (REQ-053 AC-1/2) # --------------------------------------------------------------------------- diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index e5d026c..d3fd160 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -2289,6 +2289,19 @@ class PostgresPortfolioRepository: rows = cur.fetchall() return [self._row_to_daemon(row) for row in rows] + def list_daemons_all(self) -> list[Daemon]: + """Return all daemons across all owners (admin use only).""" + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at + FROM daemons ORDER BY created_at ASC + """, + ) + rows = cur.fetchall() + return [self._row_to_daemon(row) for row in rows] + def update_daemon( self, daemon_id: str, diff --git a/app/schemas/v1.py b/app/schemas/v1.py index b4588b2..600c080 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -375,3 +375,22 @@ class DaemonAdminResponse(BaseModel): display_name: str | None = None owner_user_id: str is_active: bool + + +# --------------------------------------------------------------------------- +# GET /v1/admin/daemons (REQ-053 목록 조회) +# --------------------------------------------------------------------------- + +class DaemonDetail(BaseModel): + """Single daemon item for admin list — matches trex-web DaemonDetail contract.""" + id: str + daemon_slug: str + display_name: str | None = None + is_active: bool + owner_user_id: str + + +class DaemonDetailListResponse(BaseModel): + """Response for GET /v1/admin/daemons — matches trex-web DaemonDetailListResponse.""" + daemons: list[DaemonDetail] + total: int diff --git a/tests/test_bat022_req053_admin_daemon_crud.py b/tests/test_bat022_req053_admin_daemon_crud.py index 56a5de5..ddd500c 100644 --- a/tests/test_bat022_req053_admin_daemon_crud.py +++ b/tests/test_bat022_req053_admin_daemon_crud.py @@ -593,6 +593,106 @@ def test_update_daemon_owner_rollback_on_update_failure_prevents_leak() -> None: assert not is_commit(), "Transaction must NOT commit on UPDATE failure" +# --------------------------------------------------------------------------- +# GET /v1/admin/daemons — 전체 데몬 목록 (admin 전용) +# --------------------------------------------------------------------------- + +def test_admin_list_daemons_returns_200_with_all_daemons() -> None: + """GET /v1/admin/daemons: admin → 200 + daemons/total 형태로 전체 목록 반환.""" + settings = _make_settings() + principal = _make_admin_principal() + daemon_a = _make_daemon(daemon_id="d-001", owner_user_id=OWNER_A_ID, daemon_slug="trex-orb", display_name="ORB") + daemon_b = _make_daemon(daemon_id="d-002", owner_user_id=OWNER_B_ID, daemon_slug="trex-tsl", display_name="TSL") + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.list_daemons_all.return_value = [daemon_a, daemon_b] + + 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.get("/v1/admin/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + body = resp.json() + assert "daemons" in body + assert "total" in body + assert body["total"] == 2 + ids = {d["id"] for d in body["daemons"]} + assert ids == {"d-001", "d-002"} + # 각 항목에 필수 필드 포함 확인 + for item in body["daemons"]: + assert "id" in item + assert "daemon_slug" in item + assert "display_name" in item + assert "is_active" in item + assert "owner_user_id" in item + # 시크릿 필드 없음 확인 + assert "app_key" not in body["daemons"][0] + assert "app_secret" not in body["daemons"][0] + assert "credential_payload" not in body["daemons"][0] + + +def test_admin_list_daemons_empty_returns_200() -> None: + """GET /v1/admin/daemons: 데몬 없을 때 → 200 + daemons=[] total=0.""" + settings = _make_settings() + principal = _make_admin_principal() + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.list_daemons_all.return_value = [] + + 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.get("/v1/admin/daemons", headers=_auth_header()) + + assert resp.status_code == 200 + body = resp.json() + assert body["daemons"] == [] + assert body["total"] == 0 + + +def test_admin_list_daemons_non_admin_returns_403() -> None: + """GET /v1/admin/daemons: 비admin user token → 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.get("/v1/admin/daemons", headers=_auth_header("user-token")) + + assert resp.status_code == 403 + + +def test_admin_list_daemons_no_token_returns_401() -> None: + """GET /v1/admin/daemons: 미인증 → 401.""" + settings = _make_settings() + client = _make_client(settings, MagicMock()) + resp = client.get("/v1/admin/daemons") + assert resp.status_code == 401 + + +def test_admin_list_daemons_service_token_returns_403() -> None: + """GET /v1/admin/daemons: service-token → 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.get("/v1/admin/daemons", headers=_auth_header("svc-token")) + + assert resp.status_code == 403 + + def test_update_daemon_owner_both_ops_in_single_transaction() -> None: """BAT-022 QC Major: DELETE + UPDATE가 단일 conn.transaction() 블록 안에서 실행됨을 확인.