feat: implement daemon model with registry and single-asset assignment (BAT-010, REQ-023/028)
Core Implementation (BAT-010):
- Add daemon registry schema and CRUD operations (req-023)
- Implement single-daemon asset assignment constraint (one asset per daemon)
- Add daemon_id field to service_token for daemon identification
- Add daemon.py CLI module for daemon management commands
- Add 20260513_0016_daemon_model.py migration for daemon tables
API Changes:
- GET /v1/assets: include daemon_id in AssetResponse
- POST /v1/assets/{asset_id}/ledger/trades: require daemon token binding
- GET /v1/user_info: include daemon metadata
- Enhanced service-token rotate to preserve daemon bindings
CLI Commands:
- pfhub daemon register --name <name> --runtime-id <runtime-id>
- pfhub daemon list
- pfhub daemon get <daemon-id>
- pfhub daemon revoke <daemon-id>
Requirements (REQ-023, REQ-028):
- REQ-023: Daemon Registry and Service Token Daemon Binding (Interface, Approved)
* AC-1: daemon CRUD operations
* AC-2: service token daemon binding for POST /v1/assets/{asset_id}/ledger/trades
* AC-3: single daemon per asset (exact-one assignment model)
- REQ-028: README.md Daemon Provisioning Runbook update (Documentation, Approved)
Documentation:
- Add dcy-006 Discovery for daemon-id and single-daemon asset assignment
- Update README.md with Daemon Provisioning Runbook section
- Add BAT-010 batch documentation with planning, implementation, verification
- Update rel-006 release document (daemon model implementation)
- Update data-model.md with daemon and daemon_asset_assignment tables
- Update interface-contract.md with daemon registry APIs
- Update runtime-flows.md with daemon lifecycle flows
Breaking Changes:
- REQ-005~009, REQ-011~012 status reverted to Approved due to revised contract
* daemon_id field addition and exact-one asset assignment requirement
* Existing Implemented evidence invalidated by contract revision
* BAT-001/002/008 re-verification required
Testing:
- Add test_daemon_registry.py for daemon CRUD operations
- Add test_daemon_assignment.py for single-asset assignment constraints
- Update test_auth.py, test_cli.py, test_v1.py for daemon support
- Verify daemon token binding in trade endpoint authorization
Impact:
- Database: 2 new tables (daemons, daemon_asset_assignments)
- Migration: 20260513_0016 creates daemon model schema
- CLI: 4 new daemon subcommands
- API: daemon_id propagation through v1 endpoints
- Verification: BAT-001/002/008 re-verification gates next release
This commit is contained in:
@@ -99,6 +99,7 @@ class _AuthRepo:
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
daemon_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
id=f"token-{self._seq}",
|
||||
@@ -110,6 +111,7 @@ class _AuthRepo:
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
runtime_id=runtime_id,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._seq += 1
|
||||
self.tokens[token.id] = token
|
||||
|
||||
@@ -123,6 +123,7 @@ class FakeRepo:
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
daemon_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
id=f"token-{self._token_seq}",
|
||||
@@ -134,6 +135,7 @@ class FakeRepo:
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
runtime_id=runtime_id,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._token_seq += 1
|
||||
self._auth_tokens[token.id] = token
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Tests for daemon assignment authorization on v1 endpoints (REQ-006, REQ-007, REQ-008)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.auth import AuthContext, Permission
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import Portfolio, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
return Settings(allowed_origins_raw="http://localhost:3000", **kwargs)
|
||||
|
||||
|
||||
def _svc_header(token: str = "svc-token") -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _app_with_db(monkeypatch) -> TestClient:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
s = _make_settings()
|
||||
app = create_app(settings_factory=lambda: s)
|
||||
app.dependency_overrides[get_settings] = lambda: s
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _principal(daemon_id: str | None = "d-1") -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="trade-write",
|
||||
token_type="service", runtime_id="rt-1", daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
|
||||
def _portfolio_with_daemon(daemon_id: str | None = "d-1") -> MagicMock:
|
||||
p = MagicMock(spec=Portfolio)
|
||||
p.managed_by_daemon_id = daemon_id
|
||||
p.portfolio_id = "asset-1"
|
||||
return p
|
||||
|
||||
|
||||
def _ledger_trade_payload() -> dict:
|
||||
return {
|
||||
"order_id": "ord-1",
|
||||
"execution_id": "exec-1",
|
||||
"side": "buy",
|
||||
"quantity": "10",
|
||||
"price": "50000",
|
||||
"fee_amount": "0",
|
||||
"tax_amount": "0",
|
||||
"executed_at": "2026-05-13T10:00:00+09:00",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-006: GET /v1/assets daemon-scoped + NULL fail-closed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_assets_403_when_token_has_no_daemon_id(monkeypatch) -> None:
|
||||
client = _app_with_db(monkeypatch)
|
||||
principal = _principal(daemon_id=None)
|
||||
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):
|
||||
resp = client.get("/v1/assets", headers=_svc_header())
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "daemon_id" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None:
|
||||
client = _app_with_db(monkeypatch)
|
||||
principal = _principal(daemon_id="d-1")
|
||||
portfolio = Portfolio(
|
||||
portfolio_id="p1", broker="kis", status="enabled", name="T",
|
||||
symbol="005930", market="KRW", cash_balance=0.0, asset_quantity=0.0,
|
||||
price=0.0, profit_buffer=0.0, owner_user_id="user-1", managed_by_daemon_id="d-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
|
||||
mock_repo.get_strategy_set.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.get("/v1/assets", headers=_svc_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
|
||||
owner_user_id="user-1",
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id="d-1",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-007: GET /v1/user_info daemon_id field + NULL diagnostic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_returns_daemon_id_in_response(monkeypatch) -> None:
|
||||
client = _app_with_db(monkeypatch)
|
||||
principal = _principal(daemon_id="d-1")
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.get("/v1/user_info", headers=_svc_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["daemon_id"] == "d-1"
|
||||
|
||||
|
||||
def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None:
|
||||
client = _app_with_db(monkeypatch)
|
||||
principal = _principal(daemon_id=None)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.get("/v1/user_info", headers=_svc_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["daemon_id"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-008: POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _setup_ledger_mocks(monkeypatch, principal: TokenPrincipal, portfolio: MagicMock, trade: MagicMock) -> tuple[MagicMock, TestClient]:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
get_settings.cache_clear()
|
||||
s = _make_settings(credential_encryption_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
app = create_app(settings_factory=lambda: s)
|
||||
app.dependency_overrides[get_settings] = lambda: s
|
||||
client = TestClient(app)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get.return_value = portfolio
|
||||
mock_repo.insert_ledger_trade.return_value = (trade, True)
|
||||
return mock_repo, client
|
||||
|
||||
|
||||
def test_ledger_trade_403_when_token_has_no_daemon_id(monkeypatch) -> None:
|
||||
from app.domain.models import LedgerTrade
|
||||
principal = _principal(daemon_id=None)
|
||||
portfolio = _portfolio_with_daemon(daemon_id="d-1")
|
||||
trade = MagicMock(spec=LedgerTrade)
|
||||
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.post(
|
||||
"/v1/assets/asset-1/ledger/trades",
|
||||
json=_ledger_trade_payload(),
|
||||
headers=_svc_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "daemon_id" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_ledger_trade_403_when_asset_has_no_daemon_assignment(monkeypatch) -> None:
|
||||
from app.domain.models import LedgerTrade
|
||||
principal = _principal(daemon_id="d-1")
|
||||
portfolio = _portfolio_with_daemon(daemon_id=None)
|
||||
trade = MagicMock(spec=LedgerTrade)
|
||||
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.post(
|
||||
"/v1/assets/asset-1/ledger/trades",
|
||||
json=_ledger_trade_payload(),
|
||||
headers=_svc_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "not assigned" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_ledger_trade_403_when_daemon_mismatch(monkeypatch) -> None:
|
||||
from app.domain.models import LedgerTrade
|
||||
principal = _principal(daemon_id="d-1")
|
||||
portfolio = _portfolio_with_daemon(daemon_id="d-2")
|
||||
trade = MagicMock(spec=LedgerTrade)
|
||||
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.post(
|
||||
"/v1/assets/asset-1/ledger/trades",
|
||||
json=_ledger_trade_payload(),
|
||||
headers=_svc_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "mismatch" in resp.json()["detail"].lower() or "daemon" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_ledger_trade_201_when_daemon_matches(monkeypatch) -> None:
|
||||
from app.domain.models import LedgerTrade
|
||||
principal = _principal(daemon_id="d-1")
|
||||
portfolio = _portfolio_with_daemon(daemon_id="d-1")
|
||||
trade = MagicMock()
|
||||
trade.ledger_entry_id = 1
|
||||
trade.asset_id = "asset-1"
|
||||
trade.order_id = "ord-1"
|
||||
trade.execution_id = "exec-1"
|
||||
trade.side = "buy"
|
||||
trade.quantity = 10.0
|
||||
trade.price = 50000.0
|
||||
trade.fee_amount = 0.0
|
||||
trade.tax_amount = 0.0
|
||||
trade.executed_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc)
|
||||
trade.recorded_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc)
|
||||
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.post(
|
||||
"/v1/assets/asset-1/ledger/trades",
|
||||
json=_ledger_trade_payload(),
|
||||
headers=_svc_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["status"] == "new"
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Tests for daemon registry CRUD and service-token daemon binding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.models import AuthToken, Daemon, User
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
from app.services.auth_service import AuthService
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal in-memory repo for daemon registry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class _DaemonRepo:
|
||||
def __post_init__(self) -> None:
|
||||
self._users: dict[str, User] = {}
|
||||
self._tokens: dict[str, AuthToken] = {}
|
||||
self._daemons: dict[str, Daemon] = {}
|
||||
self._daemons_by_slug: dict[tuple[str, str], Daemon] = {}
|
||||
self._seq = 1
|
||||
self._daemon_seq = 1
|
||||
|
||||
def get_user_by_username(self, username: str) -> User | None:
|
||||
return next((u for u in self._users.values() if u.username == username), None)
|
||||
|
||||
def get_user(self, user_id: str) -> User:
|
||||
u = self._users.get(user_id)
|
||||
if u is None:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
return u
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
permission: str,
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
daemon_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
id=f"token-{self._seq}",
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
token_prefix=token_prefix,
|
||||
permission=permission,
|
||||
token_type=token_type,
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
runtime_id=runtime_id,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._seq += 1
|
||||
self._tokens[token.id] = token
|
||||
return token
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||
return next((t for t in self._tokens.values() if t.name == name and t.token_type == "service"), None)
|
||||
|
||||
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
|
||||
token = self._tokens.get(token_id)
|
||||
if token is None or token.user_id != user_id:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
|
||||
return self._daemons.get(daemon_id)
|
||||
|
||||
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None:
|
||||
return self._daemons_by_slug.get((owner_user_id, daemon_slug))
|
||||
|
||||
def create_daemon(self, owner_user_id: str, *, daemon_slug: str, display_name: str | None = None) -> Daemon:
|
||||
key = (owner_user_id, daemon_slug)
|
||||
if key in self._daemons_by_slug:
|
||||
raise ValueError(f"이미 존재하는 daemon slug입니다: {daemon_slug}")
|
||||
daemon = Daemon(
|
||||
id=f"daemon-{self._daemon_seq}",
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug=daemon_slug,
|
||||
display_name=display_name,
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._daemon_seq += 1
|
||||
self._daemons[daemon.id] = daemon
|
||||
self._daemons_by_slug[key] = daemon
|
||||
return daemon
|
||||
|
||||
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]:
|
||||
return [d for d in self._daemons.values() if d.owner_user_id == owner_user_id]
|
||||
|
||||
def update_daemon(self, daemon_id: str, *, display_name: str | None = None, is_active: bool | None = None) -> Daemon:
|
||||
daemon = self._daemons.get(daemon_id)
|
||||
if daemon is None:
|
||||
raise ValueError("데몬을 찾을 수 없습니다")
|
||||
updated = Daemon(
|
||||
id=daemon.id,
|
||||
owner_user_id=daemon.owner_user_id,
|
||||
daemon_slug=daemon.daemon_slug,
|
||||
display_name=display_name if display_name is not None else daemon.display_name,
|
||||
is_active=is_active if is_active is not None else daemon.is_active,
|
||||
created_at=daemon.created_at,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._daemons[daemon_id] = updated
|
||||
self._daemons_by_slug[(updated.owner_user_id, updated.daemon_slug)] = updated
|
||||
return updated
|
||||
|
||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
|
||||
existing = self.get_service_token_by_name(name)
|
||||
if existing is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
existing.revoked_at = datetime.now(timezone.utc)
|
||||
import uuid as _uuid
|
||||
from app.core.security import generate_token, hash_token, token_prefix as _tp
|
||||
raw = generate_token()
|
||||
new_token = AuthToken(
|
||||
id=f"token-{self._seq}",
|
||||
user_id=existing.user_id,
|
||||
name=name,
|
||||
token_prefix=_tp(raw),
|
||||
permission=existing.permission,
|
||||
token_type="service",
|
||||
expires_at=existing.expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
runtime_id=str(_uuid.uuid4()),
|
||||
daemon_id=existing.daemon_id,
|
||||
)
|
||||
self._seq += 1
|
||||
self._tokens[new_token.id] = new_token
|
||||
return raw, new_token
|
||||
|
||||
|
||||
def _repo_with_user(username: str = "alice") -> _DaemonRepo:
|
||||
repo = _DaemonRepo()
|
||||
repo._users["user-1"] = User(id="user-1", username=username, permission="read")
|
||||
return repo
|
||||
|
||||
|
||||
def _service(repo: _DaemonRepo) -> AuthService:
|
||||
return AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-023: Daemon registry CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_daemon_returns_daemon_id() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
|
||||
assert daemon.id is not None
|
||||
assert daemon.daemon_slug == "alpha-bot"
|
||||
assert daemon.owner_user_id == "user-1"
|
||||
assert daemon.is_active is True
|
||||
|
||||
|
||||
def test_create_daemon_with_display_name() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="beta-bot", display_name="Beta Trading Bot")
|
||||
|
||||
assert daemon.display_name == "Beta Trading Bot"
|
||||
|
||||
|
||||
def test_update_daemon_display_name() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot", display_name="Old Name")
|
||||
|
||||
updated = repo.update_daemon(daemon.id, display_name="New Name")
|
||||
|
||||
assert updated.display_name == "New Name"
|
||||
assert updated.daemon_slug == "alpha-bot"
|
||||
|
||||
|
||||
def test_create_daemon_duplicate_slug_raises() -> None:
|
||||
repo = _repo_with_user()
|
||||
repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
|
||||
with pytest.raises(ValueError, match="alpha-bot"):
|
||||
repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
|
||||
|
||||
def test_list_daemons_scoped_to_user() -> None:
|
||||
repo = _repo_with_user()
|
||||
repo._users["user-2"] = User(id="user-2", username="bob", permission="read")
|
||||
repo.create_daemon("user-1", daemon_slug="alice-bot")
|
||||
repo.create_daemon("user-2", daemon_slug="bob-bot")
|
||||
|
||||
alice_daemons = repo.list_daemons_for_user("user-1")
|
||||
bob_daemons = repo.list_daemons_for_user("user-2")
|
||||
|
||||
assert len(alice_daemons) == 1
|
||||
assert alice_daemons[0].daemon_slug == "alice-bot"
|
||||
assert len(bob_daemons) == 1
|
||||
assert bob_daemons[0].daemon_slug == "bob-bot"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-005: service-token issue daemon binding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_issue_service_token_with_daemon_id() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
service = _service(repo)
|
||||
|
||||
result = service.issue_service_token("my-token", "alice", daemon_id=daemon.id)
|
||||
|
||||
assert result.auth_token.daemon_id == daemon.id
|
||||
|
||||
|
||||
def test_issue_service_token_unknown_daemon_raises() -> None:
|
||||
repo = _repo_with_user()
|
||||
service = _service(repo)
|
||||
|
||||
with pytest.raises(ValueError, match="존재하지 않거나 비활성"):
|
||||
service.issue_service_token("my-token", "alice", daemon_id="nonexistent-daemon-id")
|
||||
|
||||
|
||||
def test_issue_service_token_inactive_daemon_raises() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
repo.update_daemon(daemon.id, is_active=False)
|
||||
service = _service(repo)
|
||||
|
||||
with pytest.raises(ValueError, match="존재하지 않거나 비활성"):
|
||||
service.issue_service_token("my-token", "alice", daemon_id=daemon.id)
|
||||
|
||||
|
||||
def test_issue_service_token_without_daemon_id_succeeds() -> None:
|
||||
repo = _repo_with_user()
|
||||
service = _service(repo)
|
||||
|
||||
result = service.issue_service_token("my-token", "alice")
|
||||
|
||||
assert result.auth_token.daemon_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-012: service-token rotate daemon_id invariant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_rotate_service_token_preserves_daemon_id() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
service = _service(repo)
|
||||
|
||||
issued = service.issue_service_token("my-token", "alice", daemon_id=daemon.id)
|
||||
original_daemon_id = issued.auth_token.daemon_id
|
||||
original_runtime_id = issued.auth_token.runtime_id
|
||||
|
||||
rotated = service.rotate_service_token("my-token")
|
||||
|
||||
assert rotated.auth_token.daemon_id == original_daemon_id
|
||||
assert rotated.auth_token.runtime_id != original_runtime_id
|
||||
assert rotated.auth_token.name == "my-token"
|
||||
|
||||
|
||||
def test_rotate_service_token_generates_new_raw_token() -> None:
|
||||
repo = _repo_with_user()
|
||||
service = _service(repo)
|
||||
|
||||
issued = service.issue_service_token("my-token", "alice")
|
||||
rotated = service.rotate_service_token("my-token")
|
||||
|
||||
assert rotated.token != issued.token
|
||||
|
||||
|
||||
def test_rotate_nonexistent_token_raises() -> None:
|
||||
repo = _repo_with_user()
|
||||
service = _service(repo)
|
||||
|
||||
with pytest.raises(ValueError, match="nonexistent"):
|
||||
service.rotate_service_token("nonexistent")
|
||||
+31
-15
@@ -87,6 +87,7 @@ class _RuntimeIdRoundTripRepo:
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
daemon_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
id=f"token-{self._seq}",
|
||||
@@ -98,6 +99,7 @@ class _RuntimeIdRoundTripRepo:
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
runtime_id=runtime_id,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._seq += 1
|
||||
self.tokens_by_hash[token_hash] = token
|
||||
@@ -113,6 +115,7 @@ class _RuntimeIdRoundTripRepo:
|
||||
permission=token.permission,
|
||||
token_type=token.token_type,
|
||||
runtime_id=token.runtime_id,
|
||||
daemon_id=token.daemon_id,
|
||||
)
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
@@ -226,6 +229,7 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
|
||||
active_strategy_id=None,
|
||||
avabile_strategies=None,
|
||||
owner_user_id="user-1",
|
||||
managed_by_daemon_id="d-1",
|
||||
)
|
||||
|
||||
|
||||
@@ -253,7 +257,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
portfolio = _make_portfolio()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -280,6 +284,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
|
||||
owner_user_id="user-1",
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id="d-1",
|
||||
)
|
||||
|
||||
|
||||
@@ -294,7 +299,7 @@ def test_v1_assets_status_filter(monkeypatch) -> None:
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -328,7 +333,7 @@ def test_v1_assets_strategy_params(monkeypatch) -> None:
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -357,7 +362,7 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -379,23 +384,27 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None:
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id=None, # no runtime_id
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/user_info", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert "runtime_id" in resp.json()["detail"]
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["runtime_id"] is None
|
||||
|
||||
|
||||
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
@@ -539,7 +548,7 @@ def _make_ledger_trade(**kwargs) -> LedgerTrade:
|
||||
def _make_ledger_principal() -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="trade-write",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
|
||||
@@ -548,7 +557,9 @@ def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerTrade, created: bool, f
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = _make_ledger_principal()
|
||||
if found:
|
||||
mock_repo.get.return_value = MagicMock() # portfolio exists
|
||||
mock_portfolio = MagicMock()
|
||||
mock_portfolio.managed_by_daemon_id = "d-1"
|
||||
mock_repo.get.return_value = mock_portfolio
|
||||
else:
|
||||
mock_repo.get.side_effect = Exception("not found")
|
||||
mock_repo.insert_ledger_trade.return_value = (trade, created)
|
||||
@@ -759,6 +770,7 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
|
||||
active_strategy_id=None,
|
||||
avabile_strategies=None,
|
||||
owner_user_id="user-1",
|
||||
managed_by_daemon_id="d-1",
|
||||
)
|
||||
|
||||
|
||||
@@ -786,7 +798,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
portfolio = _make_portfolio()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -809,23 +821,27 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None:
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id=None, # no runtime_id
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app()
|
||||
resp = client.get("/v1/user_info", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert "runtime_id" in resp.json()["detail"]
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["runtime_id"] is None
|
||||
|
||||
|
||||
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user