Asset Catalog Model Implementation (BAT-001): - Introduce assets table with UUID PK, symbol UNIQUE, markets TEXT[] array - Define MarketCode enum (KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITHUMB) - Add Asset domain model with catalog properties - Implement asset CRUD protocols in portfolio_repository - Add PostgreSQL asset CRUD operations with GIN index on markets Portfolio Model Refactoring (REQ-011): - Add portfolios.asset_id UUID FK to assets(asset_id) ON DELETE RESTRICT - Remove portfolios.symbol and portfolios.market columns - Update Portfolio domain model to reference asset_id instead of symbol/market - Refactor all portfolio queries to JOIN assets catalog - Update all repository methods to use asset references Data Migration (REQ-014): - Create 20260519_0017 Alembic revision for assets table creation - Create 20260519_0018 Alembic revision for atomic data migration: * Extract unique symbol combinations from portfolios * Aggregate markets using ARRAY_AGG(DISTINCT market) * Populate assets catalog with extracted data * Update portfolios.asset_id to reference new assets * Remove legacy symbol/market columns in single transaction - Implement migration with rollback support Requirements Approved (REQ-001~008): - REQ-001~008 transitioned from Proposed to Approved - Complete tag model requirement set with implementation readiness New Requirements Approved (REQ-009~015): - REQ-009: assets table schema (approved, included in BAT-001) - REQ-010: admin asset catalog CRUD (approved, future BAT-002) - REQ-011: portfolios asset_id FK (approved, included in BAT-001) - REQ-012: portfolio create catalog validation (approved, future BAT-002) - REQ-013: GET /v1/assets token branching (approved, future BAT-002) - REQ-014: portfolios data migration (approved, included in BAT-001) - REQ-015: asset catalog baseline docs (approved) Code Changes: - app/domain/models.py: Add Asset model, MarketCode enum, Portfolio asset_id FK - app/repositories/portfolio_repository.py: Define asset CRUD protocol - app/repositories/postgres.py: Implement asset operations with full CRUD - app/api/v1_assets.py: Remove symbol/market direct references - app/cli/commands/portfolio.py: Update portfolio commands for asset references - app/schemas/portfolio.py: Update portfolio schema with asset_id - All portfolio operations throughout codebase: Use asset references Testing: - Add test_asset_catalog.py for asset CRUD operations - Verify REQ-009 AC-1~5 (asset catalog creation, lookup, uniqueness) - Verify REQ-011 AC-1~5 (FK constraint, portfolio asset reference) - Verify REQ-014 AC-1~6 (migration atomicity, data correctness, rollback) - Update existing tests for asset model usage Documentation: - Create BAT-001 batch documents (planning, design, implementation, verification) - Update dcy-001 with implementation details and confirmed architecture - Add 8 new requirement documents (REQ-009~015) - Update docs/discovery/index.md with batch entry Impact: - Database: assets catalog table, portfolios refactored to reference assets - Migration: 2 new Alembic revisions (0017 assets, 0018 portfolio migration) - API: Symbol/market now accessed through asset references - CLI: Portfolio commands require asset reference pattern - All existing portfolio data preserved and correctly migrated
249 lines
9.7 KiB
Python
249 lines
9.7 KiB
Python
"""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", 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"
|