feat(REQ-005): implement TrEx Basic V1 API contract with auth, assets, ledger, and user endpoints

- Add V1 API routes for assets, ledger, and user info with complete CRUD operations
- Implement authentication scopes and request validation via auth_routes.py
- Extend schemas with V1-specific models for accounts, holdings, and positions
- Add database queries and aggregations for P&L, performance, and account data
- Include comprehensive test coverage for all V1 endpoints and integration flows
- Update batch and release documentation with implementation and verification evidence
- Sync project structure and runtime flows per V1 API contract requirements

Closes implementation phase of BAT-001 (TrEx Basic V1 API Contract)
This commit is contained in:
2026-05-02 11:37:42 +00:00
parent ce63c75c9c
commit 3509606c7e
17 changed files with 1159 additions and 124 deletions
+551 -55
View File
@@ -11,10 +11,560 @@ from fastapi.testclient import TestClient
from app.core.auth import AuthContext, Permission
from app.core.config import Settings
from app.domain.models import AuthToken, Portfolio, TokenPrincipal, User, LedgerTrade
from app.domain.models import AuthToken, BrokerCredential, Portfolio, TokenPrincipal, User, LedgerTrade
from app.main import create_app
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Valid Fernet test key (32 null bytes base64-encoded)
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
**kwargs,
)
def _make_app(settings: Settings | None = None) -> TestClient:
s = settings or _make_settings()
return TestClient(create_app(settings_factory=lambda: s))
def _make_app_db(monkeypatch, **extra_settings) -> Settings:
"""Return settings with db_enabled=True and a valid encryption key for startup."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", _TEST_ENC_KEY)
return _make_settings(**extra_settings)
def _service_token_header(token: str = "svc-token") -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# GET /health
# ---------------------------------------------------------------------------
def test_health_returns_full_response() -> None:
client = _make_app()
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["service"] == "portfolio-hub"
assert "version" in body
assert "checked_at" in body
# checked_at should contain KST offset (+09:00)
assert "+09:00" in body["checked_at"]
# ---------------------------------------------------------------------------
# POST /auth/service-tokens (B-1: REQ-005 AC2)
# ---------------------------------------------------------------------------
def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
from app.core.config import get_settings as _get_settings
from app.core.auth import require_admin as _require_admin
from app.services.auth_service import IssuedServiceToken, AuthService, get_auth_service
issued_token = AuthToken(
id="tok-1", user_id="user-1", name="bot-1",
token_prefix="pfhub_", permission="read", token_type="service",
runtime_id="rt-new-123",
)
mock_service = MagicMock(spec=AuthService)
mock_service.issue_service_token.return_value = IssuedServiceToken(
token="pfhub_rawtoken",
auth_token=issued_token,
)
settings = _make_settings(credential_encryption_key=_TEST_ENC_KEY)
app_instance = create_app(settings_factory=lambda: settings)
admin_ctx = AuthContext(
token="admin-override",
user_id="user-1", token_source="env", token_type="session",
permission=Permission.ADMIN,
)
app_instance.dependency_overrides[_require_admin] = lambda: admin_ctx
from app.api.auth_routes import get_auth_service
app_instance.dependency_overrides[get_auth_service] = lambda: mock_service
client = TestClient(app_instance)
resp = client.post(
"/auth/service-tokens",
json={"name": "bot-1", "username": "alice"},
)
assert resp.status_code == 201
body = resp.json()
assert body["runtime_id"] == "rt-new-123"
assert "token" in body
# ---------------------------------------------------------------------------
# GET /v1/assets
# ---------------------------------------------------------------------------
def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
return Portfolio(
portfolio_id=portfolio_id,
broker="kis",
status="enabled",
name="Test",
symbol="005930",
market="KRW",
tags=["tag1"],
cash_balance=1000.0,
asset_quantity=10.0,
price=50000.0,
profit_buffer=0.0,
active_strategy_id=None,
avabile_strategies=None,
owner_user_id="user-1",
)
def test_v1_assets_requires_service_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
settings = _make_settings(db_enabled=False)
client = _make_app(settings)
resp = client.get("/v1/assets")
assert resp.status_code == 401
def test_v1_assets_rejects_env_token() -> None:
from app.core.config import get_settings
settings = _make_settings(db_enabled=False, read_tokens_raw="env-token")
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
resp = client.get("/v1/assets", headers=_service_token_header("env-token"))
assert resp.status_code == 403
def test_v1_assets_returns_asset_list(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
item = body["assets"][0]
assert item["asset_id"] == "p1"
assert item["market"] == "KRW"
assert item["broker_type"] == "kis"
assert item["cash"] == 1000.0
assert item["quantity"] == 10.0
assert item["strategy_params"] == {}
def test_v1_assets_status_filter(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
p_enabled = _make_portfolio("p1") # status="enabled"
p_active = Portfolio(
portfolio_id="p2", broker="kis", status="active", name="Active",
symbol="000660", market="KRW", cash_balance=0.0, asset_quantity=0.0,
price=0.0, profit_buffer=0.0,
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p_enabled, p_active]
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?status=active", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["assets"][0]["asset_id"] == "p2"
def test_v1_assets_strategy_params(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
from app.domain.models import StrategySet
import datetime as _dt
ss = StrategySet(id="ss-1", name="strategy", config='{"buy_threshold": 0.05}', nick="s1",
created_at=_dt.datetime.now(_dt.timezone.utc))
p = 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, active_strategy_id="ss-1",
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p]
mock_repo.get_strategy_set.return_value = ss
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["assets"][0]["strategy_params"] == {"buy_threshold": 0.05}
def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
"""strategy_set lookup failure → strategy_params == {}."""
settings = _make_app_db(monkeypatch)
p = 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, active_strategy_id="ss-bad",
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1"
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p]
mock_repo.get_strategy_set.side_effect = RuntimeError("DB error")
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["assets"][0]["strategy_params"] == {}
# ---------------------------------------------------------------------------
# GET /v1/user_info
# ---------------------------------------------------------------------------
def test_v1_user_info_404_when_no_runtime_id(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
)
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):
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"]
def test_v1_user_info_returns_user(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-abc"
)
user = User(
id="user-1", username="alice", email="alice@example.com",
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):
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 == 200
body = resp.json()
assert body["runtime_id"] == "rt-abc"
assert body["username"] == "alice"
assert body["trading_enabled"] is True
assert body["disabled_reason"] is None
assert body["brokers"] == []
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-2", permission="read",
token_type="service", runtime_id="rt-xyz"
)
user = User(
id="user-2", username="bob", email=None,
permission="read", is_active=False,
)
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):
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 == 200
body = resp.json()
assert body["trading_enabled"] is False
assert body["disabled_reason"] == "account_inactive"
def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
"""REQ-007 AC5: brokers[]에 복호화된 credential_payload 반환."""
from app.core.encryption import encrypt_payload
import json as _json
settings = _make_app_db(monkeypatch)
plain = _json.dumps({"app_key": "mykey", "app_secret": "mysecret"})
encrypted = encrypt_payload(_TEST_ENC_KEY, plain)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-abc"
)
user = User(id="user-1", username="alice", permission="read", is_active=True)
cred = BrokerCredential(
user_id="user-1", broker="kis", account_ref="12345678",
encrypted_payload=encrypted, credential_version=1,
)
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 = [cred]
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):
settings = _make_settings(credential_encryption_key=_TEST_ENC_KEY)
from app.core.config import get_settings as _gs
app_instance = create_app(settings_factory=lambda: settings)
app_instance.dependency_overrides[_gs] = lambda: settings
client = TestClient(app_instance)
resp = client.get("/v1/user_info", headers=_service_token_header())
assert resp.status_code == 200
body = resp.json()
assert len(body["brokers"]) == 1
broker = body["brokers"][0]
assert broker["broker"] == "kis"
assert broker["credential_payload"] == {"app_key": "mykey", "app_secret": "mysecret"}
# ---------------------------------------------------------------------------
# POST /v1/assets/{asset_id}/ledger/trades
# ---------------------------------------------------------------------------
def _trade_payload(**overrides) -> dict:
base = {
"order_id": "ord-001",
"execution_id": "exec-001",
"side": "buy",
"quantity": "10.5",
"price": "50000",
"fee_amount": "100",
"tax_amount": "0",
"executed_at": "2026-05-01T10:00:00+09:00",
}
base.update(overrides)
return base
def _make_ledger_trade(**kwargs) -> LedgerTrade:
defaults = dict(
ledger_entry_id=1,
asset_id="005930",
order_id="ord-001",
execution_id="exec-001",
side="buy",
quantity=10.5,
price=50000.0,
fee_amount=100.0,
tax_amount=0.0,
executed_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc),
)
defaults.update(kwargs)
return LedgerTrade(**defaults)
def _make_ledger_principal() -> TokenPrincipal:
return TokenPrincipal(
token_id="t1", user_id="user-1", permission="trade-write",
token_type="service", runtime_id="rt-1"
)
def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerTrade, created: bool, found: bool = True) -> MagicMock:
settings = _make_app_db(monkeypatch)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = _make_ledger_principal()
if found:
mock_repo.get.return_value = MagicMock() # portfolio exists
else:
mock_repo.get.side_effect = Exception("not found")
mock_repo.insert_ledger_trade.return_value = (trade, created)
return mock_repo
def test_v1_ledger_trade_created(monkeypatch) -> None:
trade = _make_ledger_trade()
mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=True)
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
assert resp.status_code == 201
body = resp.json()
assert body["status"] == "new"
assert body["asset_id"] == "005930"
def test_v1_ledger_trade_idempotent_same_payload(monkeypatch) -> None:
"""Duplicate with same payload → HTTP 200 + status='duplicate'."""
trade = _make_ledger_trade()
mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=False)
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(), # same payload
headers=_service_token_header(),
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "duplicate"
def test_v1_ledger_trade_conflict_different_payload(monkeypatch) -> None:
"""Duplicate key with different payload → HTTP 409."""
trade = _make_ledger_trade() # side=buy, quantity=10.5
mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=False)
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(quantity="99.0"), # different quantity → conflict
headers=_service_token_header(),
)
assert resp.status_code == 409
def test_v1_ledger_trade_asset_not_found(monkeypatch) -> None:
"""Non-existent asset_id → HTTP 404."""
trade = _make_ledger_trade()
mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=True, found=False)
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):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.post(
"/v1/assets/nonexistent/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# REQ-010: startup validation
# ---------------------------------------------------------------------------
def test_startup_fails_when_encryption_key_missing() -> None:
"""B-10: Settings.db_enabled=True + no key → lifespan raises RuntimeError."""
import pytest
settings = _make_settings(db_enabled=True, credential_encryption_key="")
with pytest.raises(RuntimeError, match="CREDENTIAL_ENCRYPTION_KEY"):
with TestClient(create_app(settings_factory=lambda: settings)):
pass # lifespan should raise before this
def test_startup_ok_when_encryption_key_present() -> None:
"""B-10 AC1: valid key → app starts normally."""
settings = _make_settings(db_enabled=False, credential_encryption_key=_TEST_ENC_KEY)
with TestClient(create_app(settings_factory=lambda: settings)) as client:
resp = client.get("/health")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# REQ-010: encrypt/decrypt round-trip (B-11 evidence)
# ---------------------------------------------------------------------------
def test_encrypt_decrypt_roundtrip() -> None:
"""Fernet encrypt→upsert→decrypt round-trip works for credential payload."""
import json as _json
from app.core.encryption import encrypt_payload, decrypt_payload
plain = _json.dumps({"app_key": "mykey", "app_secret": "mysecret"})
ciphertext = encrypt_payload(_TEST_ENC_KEY, plain)
# ciphertext is not plaintext
assert ciphertext != plain
assert "mykey" not in ciphertext
# roundtrip
recovered = decrypt_payload(_TEST_ENC_KEY, ciphertext)
assert _json.loads(recovered) == {"app_key": "mykey", "app_secret": "mysecret"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -229,57 +779,3 @@ def _make_ledger_trade(**kwargs) -> LedgerTrade:
)
defaults.update(kwargs)
return LedgerTrade(**defaults)
def test_v1_ledger_trade_created(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="trade-write",
token_type="service", runtime_id="rt-1"
)
trade = _make_ledger_trade()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.insert_ledger_trade.return_value = (trade, True)
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):
client = _make_app()
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
assert resp.status_code == 201
body = resp.json()
assert body["created"] is True
assert body["asset_id"] == "005930"
def test_v1_ledger_trade_idempotent(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="trade-write",
token_type="service", runtime_id="rt-1"
)
trade = _make_ledger_trade()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.insert_ledger_trade.return_value = (trade, False) # already existed
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):
client = _make_app()
resp = client.post(
"/v1/assets/005930/ledger/trades",
json=_trade_payload(),
headers=_service_token_header(),
)
assert resp.status_code == 201
assert resp.json()["created"] is False