- Add v1_assets.py API endpoint for asset list retrieval - Add AssetResponse schema to v1.py - Implement comprehensive test coverage in test_v1.py - Update requirement verification with implementation evidence - Update SRS index to mark REQ-006 as Implemented - Document acceptance criteria validation results
785 lines
29 KiB
Python
785 lines
29 KiB
Python
"""Tests for v1 API endpoints (health, assets, user_info, ledger/trades)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
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
|
|
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"] == {}
|
|
assert item["strategy_type"] is None
|
|
|
|
|
|
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}
|
|
assert resp.json()["assets"][0]["strategy_type"] == "strategy"
|
|
|
|
|
|
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"] == {}
|
|
assert resp.json()["assets"][0]["strategy_type"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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 _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"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
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]
|
|
|
|
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()
|
|
resp = client.get("/v1/assets", headers=_service_token_header())
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["total"] == 1
|
|
assert body["assets"][0]["asset_id"] == "p1"
|
|
assert body["assets"][0]["market"] == "KRW"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/user_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_v1_user_info_404_when_no_runtime_id(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
|
|
)
|
|
|
|
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()
|
|
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:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
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
|
|
|
|
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 == 200
|
|
body = resp.json()
|
|
assert body["runtime_id"] == "rt-abc"
|
|
assert body["username"] == "alice"
|
|
assert body["disabled_reason"] is None
|
|
|
|
|
|
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
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
|
|
|
|
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 == 200
|
|
assert resp.json()["disabled_reason"] == "account_inactive"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|