fix(daemons): block owner transfer while assigned

This commit is contained in:
2026-08-15 13:20:02 +09:00
parent e2102b6f2b
commit e90519ad39
9 changed files with 284 additions and 16 deletions
+33 -1
View File
@@ -9,6 +9,7 @@ import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.errors import DaemonInUseError
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
from app.main import create_app
from app.repositories.postgres import PostgresPortfolioRepository
@@ -212,6 +213,33 @@ def test_admin_patch_owner_invalidates_credentials() -> None:
mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID)
def test_admin_patch_referenced_daemon_owner_returns_409() -> None:
"""Issue #27: assigned daemon owner transfer fails without a partial mutation."""
settings = _make_settings()
principal = _make_admin_principal()
daemon_before = _make_daemon(owner_user_id=OWNER_A_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.side_effect = DaemonInUseError(
"배정된 포트폴리오가 있는 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.patch(
f"/v1/admin/daemons/{DAEMON_ID}",
json={"owner_user_id": OWNER_B_ID},
headers=_auth_header(),
)
assert resp.status_code == 409
mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID)
mock_repo.update_daemon.assert_not_called()
# ---------------------------------------------------------------------------
# REQ-053 AC-5: owner 변경 후 이전 owner의 /v1/user_info → broker=null
# (verified via update_daemon_owner deleting credentials; AC-4 covers the mechanism)
@@ -489,7 +517,9 @@ def _make_mock_conn_for_owner_update(*, fail_on_update: bool = False):
def execute(self, sql: str, params=None) -> None:
sql_stripped = sql.strip()
executed_sqls.append(sql_stripped)
if sql_stripped.startswith("SELECT"):
if "FROM portfolios" in sql_stripped:
self._rows = []
elif sql_stripped.startswith("SELECT"):
# daemon exists
self._rows = [("daemon-001",)]
elif sql_stripped.startswith("DELETE"):
@@ -717,6 +747,8 @@ def test_update_daemon_owner_both_ops_in_single_transaction() -> None:
def fetchone(self):
sql = executed_sqls[-1] if executed_sqls else ""
if "FROM portfolios" in sql:
return None
if sql.startswith("SELECT"):
return ("daemon-001",)
if sql.startswith("UPDATE"):