fix(daemons): block owner transfer while assigned
This commit is contained in:
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user