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:
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() 블록 안에서 실행됨을 확인.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user