fix(daemons): block owner transfer while assigned
This commit is contained in:
@@ -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