feat(bat-022): daemon CRUD admin API + self-service credential management (REQ-052/053/054)

- POST/PATCH/deactivate/activate /v1/admin/daemons — admin-only daemon CRUD (REQ-053)
- GET/PUT/DELETE /v1/daemons/{daemon_id}/credential — user-owned scoped credential management (REQ-052)
- Owner re-assignment auto-invalidates BrokerCredential via update_daemon_owner() (REQ-053 AC-4)
- Deactivation auto-revokes service tokens via revoke_tokens_by_daemon_id() (REQ-022 reuse, REQ-053 AC-6)
- DELETE /v1/admin/daemons/{id} → 405 (hard delete not provided, REQ-053 AC-8)
- All responses exclude app_key/app_secret; Fernet encryption at rest (REQ-054)
- Service-token → 403 on credential endpoints (REQ-052 AC-10)
- Admin ownership scope enforced: admin cannot write other user's credential (REQ-052 AC-9)
- REQ-051 regression: 4 service-token guard tests pass (REQ-054 AC-7)
- 44 new tests, 565 total passed (521 baseline + 44 new), 0 failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-28 14:18:37 +09:00
co-authored by Claude Sonnet 4.6
parent 80d0511d7f
commit d24058d863
12 changed files with 2169 additions and 0 deletions
@@ -0,0 +1,459 @@
"""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