fix(dcy-011/bat-022): GET /v1/admin/daemons 목록 엔드포인트 추가 — UI 계약 누락(405) 해소 (라이브 통합테스트 결함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-28 18:20:13 +09:00
co-authored by Claude Opus 4.8
parent 0fbd33b08b
commit 2dcbafb7a8
4 changed files with 172 additions and 1 deletions
+40 -1
View File
@@ -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)
# ---------------------------------------------------------------------------
+13
View File
@@ -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,
+19
View File
@@ -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
@@ -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() 블록 안에서 실행됨을 확인.