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"):
+15 -1
View File
@@ -402,6 +402,20 @@ def test_patch_foreign_daemon_returns_404() -> None:
assert resp.status_code == 404
def test_patch_missing_daemon_returns_404_without_changing_portfolio() -> None:
repo = _repo_with_portfolio_and_daemons(owner=_USER_ID)
client = _make_client()
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_UUID}",
json={"managed_by_daemon_id": "ffffffff-ffff-ffff-ffff-ffffffffffff"},
headers=_auth("admin-tok"),
)
assert resp.status_code == 404
assert repo.get(_PORTFOLIO_UUID).managed_by_daemon_id is None
def test_patch_inactive_daemon_returns_409() -> None:
repo = _repo_with_portfolio_and_daemons(owner=_USER_ID, is_active=False)
client = _make_client()
@@ -412,4 +426,4 @@ def test_patch_inactive_daemon_returns_409() -> None:
json={"managed_by_daemon_id": _DAEMON_UUID},
headers=_auth("admin-tok"),
)
assert resp.status_code == 409
assert resp.status_code == 409
@@ -16,7 +16,7 @@ import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
from app.domain.errors import DaemonInactiveError, DaemonInUseError, DaemonNotFoundError
from app.main import create_app
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
@@ -79,6 +79,7 @@ def _cleanup(
asset_id: str,
owner_user_id: str,
daemon_id: str,
extra_user_ids: list[str] | None = None,
) -> None:
with repo._connect().cursor() as cur:
for portfolio_id in portfolio_ids:
@@ -89,6 +90,8 @@ def _cleanup(
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (strategy_id,))
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
for user_id in extra_user_ids or []:
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
repo.close()
@@ -322,4 +325,201 @@ def test_patch_reassignment_persists_new_daemon() -> None:
cur.execute("DELETE FROM daemons WHERE id = %s", (daemon_b.id,))
_cleanup(repo, [], strategy_id, asset_id, owner_user_id, daemon_a)
finally:
pass
pass
def test_referenced_daemon_owner_transfer_is_atomic() -> None:
repo = _repo()
strategy_id, asset_id, owner_a, daemon_id = _prepare_daemon_fixture(repo)
owner_b = f"daemon-owner-{uuid.uuid4().hex}"
portfolio_id = str(uuid.uuid4())
try:
with repo._connect().cursor() as cur:
cur.execute(
"INSERT INTO users (id, username, permission, is_active) VALUES (%s, %s, 'trade-write', TRUE)",
(owner_b, owner_b),
)
repo.upsert_broker_credential(
owner_a,
"kis",
account_ref="owner-a-account",
encrypted_payload="ciphertext",
daemon_id=daemon_id,
)
repo.create_portfolio(
portfolio_id,
owner_user_id=owner_a,
broker="kis",
status="enabled",
name="owner-transfer-blocked",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
managed_by_daemon_id=daemon_id,
)
with pytest.raises(DaemonInUseError):
repo.update_daemon_owner(daemon_id, owner_user_id=owner_b)
assert repo.get_daemon_by_id(daemon_id).owner_user_id == owner_a
assert repo.get(portfolio_id).managed_by_daemon_id == daemon_id
credential = repo.get_broker_credential_by_daemon(daemon_id, "kis")
assert credential is not None
assert credential.user_id == owner_a
_cleanup(repo, [portfolio_id], strategy_id, asset_id, owner_a, daemon_id, [owner_b])
finally:
pass
def test_unreferenced_daemon_owner_transfer_invalidates_credentials() -> None:
repo = _repo()
strategy_id, asset_id, owner_a, daemon_id = _prepare_daemon_fixture(repo)
owner_b = f"daemon-owner-{uuid.uuid4().hex}"
try:
with repo._connect().cursor() as cur:
cur.execute(
"INSERT INTO users (id, username, permission, is_active) VALUES (%s, %s, 'trade-write', TRUE)",
(owner_b, owner_b),
)
repo.upsert_broker_credential(
owner_a,
"kis",
account_ref="owner-a-account",
encrypted_payload="ciphertext",
daemon_id=daemon_id,
)
moved = repo.update_daemon_owner(daemon_id, owner_user_id=owner_b)
assert moved.owner_user_id == owner_b
assert repo.get_broker_credential_by_daemon(daemon_id, "kis") is None
_cleanup(repo, [], strategy_id, asset_id, owner_a, daemon_id, [owner_b])
finally:
pass
def test_assignment_and_owner_transfer_race_preserves_same_owner_invariant() -> None:
repo = _repo()
strategy_id, asset_id, owner_a, daemon_id = _prepare_daemon_fixture(repo)
owner_b = f"daemon-owner-{uuid.uuid4().hex}"
portfolio_id = str(uuid.uuid4())
barrier = threading.Barrier(3)
outcomes: list[str] = []
errors: list[BaseException] = []
with repo._connect().cursor() as cur:
cur.execute(
"INSERT INTO users (id, username, permission, is_active) VALUES (%s, %s, 'trade-write', TRUE)",
(owner_b, owner_b),
)
def assign_daemon() -> None:
worker = _repo()
try:
barrier.wait()
worker.create_portfolio(
portfolio_id,
owner_user_id=owner_a,
broker="kis",
status="enabled",
name="owner-transfer-race",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
managed_by_daemon_id=daemon_id,
)
outcomes.append("assigned")
except DaemonNotFoundError:
outcomes.append("assignment-rejected")
except BaseException as exc: # pragma: no cover - populated only on failure
errors.append(exc)
finally:
worker.close()
def transfer_owner() -> None:
worker = _repo()
try:
barrier.wait()
worker.update_daemon_owner(daemon_id, owner_user_id=owner_b)
outcomes.append("transferred")
except DaemonInUseError:
outcomes.append("transfer-rejected")
except BaseException as exc: # pragma: no cover - populated only on failure
errors.append(exc)
finally:
worker.close()
threads = [threading.Thread(target=assign_daemon), threading.Thread(target=transfer_owner)]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=10)
assert not thread.is_alive()
assert errors == []
assert sorted(outcomes) in (
["assigned", "transfer-rejected"],
["assignment-rejected", "transferred"],
)
daemon = repo.get_daemon_by_id(daemon_id)
portfolio = repo.get(portfolio_id) if "assigned" in outcomes else None
if portfolio is not None:
assert daemon.owner_user_id == portfolio.owner_user_id == owner_a
assert portfolio.managed_by_daemon_id == daemon_id
else:
assert daemon.owner_user_id == owner_b
_cleanup(repo, [portfolio_id], strategy_id, asset_id, owner_a, daemon_id, [owner_b])
def test_deactivation_preserves_assignment_and_revokes_bound_service_token() -> None:
repo = _repo()
strategy_id, asset_id, owner_user_id, daemon_id = _prepare_daemon_fixture(repo)
portfolio_id = str(uuid.uuid4())
try:
repo.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="deactivation-preserves-reference",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
managed_by_daemon_id=daemon_id,
)
token = repo.create_auth_token(
owner_user_id,
name=f"daemon-token-{uuid.uuid4().hex}",
token_hash=uuid.uuid4().hex,
token_prefix="pfh",
permission="trade-write",
token_type="service",
daemon_id=daemon_id,
)
repo.update_daemon(daemon_id, is_active=False)
assert repo.revoke_tokens_by_daemon_id(daemon_id) == 1
assert repo.get(portfolio_id).managed_by_daemon_id == daemon_id
with repo._connect().cursor() as cur:
cur.execute("SELECT revoked_at IS NOT NULL FROM auth_tokens WHERE id = %s", (token.id,))
assert cur.fetchone()[0] is True
_cleanup(repo, [portfolio_id], strategy_id, asset_id, owner_user_id, daemon_id)
finally:
pass