- REQ-046: BrokerCredential.daemon_id 필드 추가, repository 시그니처 갱신 - REQ-047: alembic revision Step1(daemon_id NULL 컬럼 추가), Step2(PK 전환, dev/test DB 한정) - REQ-048: backfill-daemon-id CLI subcommand (--dry-run/--force/--broker 지원) - REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달 (brokers→broker, daemon_id 없으면 403) - REQ-050: credential set/list/remove --daemon-id 옵션 추가 (daemon_slug 해석) - REQ-051: 보안 격리 - service-token 전용, web 토큰 차단, 로그 app_secret 비노출, graceful degrade 비가역 결재 게이트: REQ-047 Step2(PK 전환)는 사장님 결재 후 실DB 적용 예정(이 batch는 revision 코드만) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
940 lines
36 KiB
Python
940 lines
36 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, get_settings
|
|
from app.domain.models import AuthToken, BrokerCredential, Portfolio, TokenPrincipal, User, LedgerEntry
|
|
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:
|
|
get_settings.cache_clear()
|
|
s = settings or _make_settings()
|
|
app = create_app(settings_factory=lambda: s)
|
|
app.dependency_overrides[get_settings] = lambda: s
|
|
return TestClient(app)
|
|
|
|
|
|
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}"}
|
|
|
|
|
|
class _RuntimeIdRoundTripRepo:
|
|
def __init__(self) -> None:
|
|
self.users = {
|
|
"user-1": User(
|
|
id="user-1",
|
|
username="alice",
|
|
email="alice@example.com",
|
|
permission="read",
|
|
is_active=True,
|
|
)
|
|
}
|
|
self.users_by_username = {"alice": self.users["user-1"]}
|
|
self.tokens_by_hash: dict[str, AuthToken] = {}
|
|
self._seq = 1
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
def get_user_by_username(self, username: str) -> User | None:
|
|
return self.users_by_username.get(username)
|
|
|
|
def get_user(self, user_id: str) -> User:
|
|
user = self.users.get(user_id)
|
|
if user is None:
|
|
raise ValueError("사용자를 찾을 수 없습니다")
|
|
return user
|
|
|
|
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_by_hash[token_hash] = token
|
|
return token
|
|
|
|
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
|
|
token = self.tokens_by_hash.get(token_hash)
|
|
if token is None:
|
|
return None
|
|
return TokenPrincipal(
|
|
token_id=token.id,
|
|
user_id=token.user_id,
|
|
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, daemon_id: str | None = None) -> list[BrokerCredential]:
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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:
|
|
# REQ-049: daemon_id 없는 service token → user_info 403
|
|
# 이 테스트는 service token 발급의 runtime_id 생성을 검증하고,
|
|
# daemon_id 없는 token으로 user_info 호출 시 403을 확인한다.
|
|
repo = _RuntimeIdRoundTripRepo()
|
|
settings = _make_settings(
|
|
db_enabled=True,
|
|
admin_tokens_raw="admin-token",
|
|
credential_encryption_key=_TEST_ENC_KEY,
|
|
)
|
|
|
|
with patch("app.services.auth_service.get_portfolio_repository_from_settings", return_value=repo), \
|
|
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo), \
|
|
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=repo):
|
|
app = create_app(settings_factory=lambda: settings)
|
|
app.dependency_overrides[get_settings] = lambda: settings
|
|
client = TestClient(app)
|
|
issue_resp = client.post(
|
|
"/auth/service-tokens",
|
|
headers={"Authorization": "Bearer admin-token"},
|
|
json={"name": "bot-1", "username": "alice"},
|
|
)
|
|
|
|
assert issue_resp.status_code == 201
|
|
issue_body = issue_resp.json()
|
|
assert issue_body["runtime_id"] is not None
|
|
assert "token" in issue_body
|
|
|
|
# REQ-049 AC-3: daemon_id 없는 service token → user_info 403
|
|
user_info_resp = client.get(
|
|
"/v1/user_info",
|
|
headers=_service_token_header(issue_body["token"]),
|
|
)
|
|
|
|
assert user_info_resp.status_code == 403
|
|
|
|
|
|
def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monkeypatch) -> None:
|
|
repo = _RuntimeIdRoundTripRepo()
|
|
settings = _make_settings(
|
|
db_enabled=True,
|
|
admin_tokens_raw="admin-token",
|
|
credential_encryption_key=_TEST_ENC_KEY,
|
|
)
|
|
|
|
with patch("app.services.auth_service.get_portfolio_repository_from_settings", return_value=repo):
|
|
app = create_app(settings_factory=lambda: settings)
|
|
app.dependency_overrides[get_settings] = lambda: settings
|
|
client = TestClient(app)
|
|
first = client.post(
|
|
"/auth/service-tokens",
|
|
headers={"Authorization": "Bearer admin-token"},
|
|
json={"name": "bot-1", "username": "alice"},
|
|
)
|
|
second = client.post(
|
|
"/auth/service-tokens",
|
|
headers={"Authorization": "Bearer admin-token"},
|
|
json={"name": "bot-2", "username": "alice"},
|
|
)
|
|
|
|
assert first.status_code == 201
|
|
assert second.status_code == 201
|
|
assert first.json()["runtime_id"] is not None
|
|
assert second.json()["runtime_id"] is not None
|
|
assert first.json()["runtime_id"] != second.json()["runtime_id"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/user_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
|
# REQ-049: daemon_id 있음 + runtime_id=None → 200 반환
|
|
settings = _make_app_db(monkeypatch)
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id=None, daemon_id="daemon-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):
|
|
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
|
|
assert resp.json()["runtime_id"] is None
|
|
|
|
|
|
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
|
# REQ-049: daemon_id 있음 + broker=None → 200 반환
|
|
settings = _make_app_db(monkeypatch)
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
|
|
)
|
|
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
|
|
# REQ-049 AC-6: broker 단일 필드 존재, brokers 리스트 미존재
|
|
assert "broker" in body
|
|
assert "brokers" not in body
|
|
assert body["broker"] is None # 인증정보 없음 → null
|
|
|
|
|
|
def test_v1_user_info_no_daemon_id_returns_403(monkeypatch) -> None:
|
|
"""REQ-049 AC-3, REQ-051 AC-3: daemon_id=None 서비스 토큰 → 403."""
|
|
settings = _make_app_db(monkeypatch)
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-abc", 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), \
|
|
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 == 403
|
|
|
|
|
|
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
|
# daemon_id 있음 + 비활성 user → disabled_reason
|
|
settings = _make_app_db(monkeypatch)
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-2", permission="read",
|
|
token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2",
|
|
)
|
|
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_credential(monkeypatch) -> None:
|
|
"""REQ-049 AC-1: daemon_id 있음 + broker 인증정보 있음 → 단일 broker 반환."""
|
|
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", daemon_id="daemon-1",
|
|
)
|
|
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,
|
|
daemon_id="daemon-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()
|
|
# REQ-049 AC-6: broker 단일 필드, brokers 리스트 없음
|
|
assert "broker" in body
|
|
assert "brokers" not in body
|
|
broker = body["broker"]
|
|
assert broker is not None
|
|
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) -> LedgerEntry:
|
|
defaults = dict(
|
|
ledger_entry_id=1,
|
|
portfolio_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 LedgerEntry(**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", daemon_id="d-1"
|
|
)
|
|
|
|
|
|
def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerEntry, 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_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)
|
|
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_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
|
resp = client.post(
|
|
"/v1/portfolios/005930/ledger/trades",
|
|
json=_trade_payload(),
|
|
headers=_service_token_header(),
|
|
)
|
|
|
|
assert resp.status_code == 201
|
|
body = resp.json()
|
|
assert body["status"] == "new"
|
|
assert body["portfolio_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_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
|
resp = client.post(
|
|
"/v1/portfolios/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_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
|
resp = client.post(
|
|
"/v1/portfolios/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 portfolio_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_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
|
resp = client.post(
|
|
"/v1/portfolios/nonexistent/ledger/trades",
|
|
json=_trade_payload(),
|
|
headers=_service_token_header(),
|
|
)
|
|
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_v1_ledger_trade_requires_trade_write_permission(monkeypatch) -> None:
|
|
"""Read-only service token must be rejected before asset lookup."""
|
|
_make_app_db(monkeypatch)
|
|
|
|
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
|
|
|
|
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.post(
|
|
"/v1/portfolios/005930/ledger/trades",
|
|
json=_trade_payload(),
|
|
headers=_service_token_header(),
|
|
)
|
|
|
|
assert resp.status_code == 403
|
|
assert resp.json()["detail"] == "Insufficient permission"
|
|
mock_repo.get.assert_not_called()
|
|
mock_repo.insert_ledger_trade.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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:
|
|
get_settings.cache_clear()
|
|
s = settings or _make_settings()
|
|
app = create_app(settings_factory=lambda: s)
|
|
app.dependency_overrides[get_settings] = lambda: s
|
|
return TestClient(app)
|
|
|
|
|
|
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",
|
|
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",
|
|
managed_by_daemon_id="d-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_env_read_token_returns_200(monkeypatch) -> None:
|
|
"""REQ-044: env read token is now accepted (require_read) — list_catalog_assets."""
|
|
from app.core.config import get_settings
|
|
from app.domain.models import Asset
|
|
import datetime as _dt
|
|
settings = _make_settings(db_enabled=False, read_tokens_raw="env-token")
|
|
mock_repo = MagicMock()
|
|
mock_repo.list_assets.return_value = []
|
|
mock_repo.get_tags_by_ids.return_value = {}
|
|
app = create_app(settings_factory=lambda: settings)
|
|
app.dependency_overrides[get_settings] = lambda: settings
|
|
client = TestClient(app)
|
|
with patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
|
resp = client.get("/v1/assets", headers=_service_token_header("env-token"))
|
|
assert resp.status_code == 200
|
|
assert resp.json()["total"] == 0
|
|
|
|
|
|
def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
|
"""REQ-044: GET /v1/assets now accepts read service token — returns 200 with catalog list."""
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
|
)
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.list_assets.return_value = []
|
|
mock_repo.get_tags_by_ids.return_value = {}
|
|
|
|
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
|
|
assert resp.json()["total"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REQ-010 CHG-20260521-02: GET /v1/assets admin catalog (path 분리)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_v1_assets_user_token(monkeypatch) -> None:
|
|
"""REQ-044: user read token is now accepted (require_read) — returns 200."""
|
|
settings = _make_app_db(monkeypatch)
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="user", runtime_id="rt-1",
|
|
)
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.list_assets.return_value = []
|
|
mock_repo.get_tags_by_ids.return_value = {}
|
|
|
|
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()["total"] == 0
|
|
|
|
|
|
def test_v1_assets_admin_token_default(monkeypatch) -> None:
|
|
"""Admin token returns catalog list — REQ-010 AC-9."""
|
|
import datetime as _dt
|
|
from app.domain.models import Asset
|
|
settings = _make_app_db(monkeypatch)
|
|
asset = Asset(
|
|
asset_id="a1-uuid", symbol="005930", markets=["KRX"],
|
|
display_name="Samsung", status="active",
|
|
created_at=_dt.datetime.now(_dt.timezone.utc),
|
|
updated_at=_dt.datetime.now(_dt.timezone.utc),
|
|
)
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="admin-1", permission="admin",
|
|
token_type="admin", runtime_id="rt-1",
|
|
)
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.list_assets.return_value = [asset]
|
|
|
|
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["items"][0]
|
|
assert item["symbol"] == "005930"
|
|
assert item["status"] == "active"
|
|
assert "portfolio_id" not in item
|
|
mock_repo.list_assets.assert_called_once()
|
|
|
|
|
|
def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
|
|
"""user_id param is not supported on catalog endpoint — returns 200 with full catalog."""
|
|
import datetime as _dt
|
|
from app.domain.models import Asset
|
|
settings = _make_app_db(monkeypatch)
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="admin-1", permission="admin",
|
|
token_type="admin", runtime_id="rt-1",
|
|
)
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.list_assets.return_value = []
|
|
|
|
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?user_id=other-user", headers=_service_token_header())
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["total"] == 0
|
|
mock_repo.list_assets.assert_called_once()
|
|
|
|
|
|
def test_v1_assets_market_filter(monkeypatch) -> None:
|
|
"""REQ-044: read service token with market filter now returns 200 (require_read)."""
|
|
from app.domain.models import Asset
|
|
import datetime as _dt
|
|
settings = _make_app_db(monkeypatch)
|
|
krx_asset = Asset(
|
|
asset_id="a-krx", symbol="005930", markets=["KRX"],
|
|
display_name="Samsung", status="active",
|
|
created_at=_dt.datetime.now(_dt.timezone.utc),
|
|
updated_at=_dt.datetime.now(_dt.timezone.utc),
|
|
)
|
|
nasdaq_asset = Asset(
|
|
asset_id="a-nasdaq", symbol="AAPL", markets=["NASDAQ"],
|
|
display_name="Apple", status="active",
|
|
created_at=_dt.datetime.now(_dt.timezone.utc),
|
|
updated_at=_dt.datetime.now(_dt.timezone.utc),
|
|
)
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-1", daemon_id="d-1",
|
|
)
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.list_assets.return_value = [krx_asset, nasdaq_asset]
|
|
mock_repo.get_tags_by_ids.return_value = {}
|
|
|
|
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?market=KRX", headers=_service_token_header())
|
|
|
|
assert resp.status_code == 200
|
|
items = resp.json()["items"]
|
|
assert len(items) == 1
|
|
assert items[0]["symbol"] == "005930"
|
|
|
|
|
|
def test_v1_assets_read_token_200(monkeypatch) -> None:
|
|
"""REQ-044: read service token accepted by GET /v1/assets (require_read)."""
|
|
settings = _make_app_db(monkeypatch)
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-1", daemon_id="d-1",
|
|
)
|
|
mock_repo = MagicMock()
|
|
mock_repo.get_token_principal.return_value = principal
|
|
mock_repo.list_assets.return_value = []
|
|
mock_repo.get_tags_by_ids.return_value = {}
|
|
|
|
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()["total"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/user_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_v1_user_info_null_runtime_id_returns_200_with_null_b(monkeypatch) -> None:
|
|
# REQ-049: daemon_id 있음 + runtime_id=None → 200
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id=None, daemon_id="daemon-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):
|
|
client = _make_app()
|
|
resp = client.get("/v1/user_info", headers=_service_token_header())
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["runtime_id"] is None
|
|
|
|
|
|
def test_v1_user_info_returns_user_b(monkeypatch) -> None:
|
|
# REQ-049 AC-9 회귀: daemon_id 있음 → 기존 필드들 정상 반환
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
|
|
)
|
|
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()
|
|
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
|
|
# REQ-049 AC-6: broker 단일 필드, brokers 키 없음
|
|
assert "broker" in body
|
|
assert "brokers" not in body
|
|
|
|
|
|
def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch) -> None:
|
|
"""REQ-049 AC-2, REQ-051 AC-6: broker_credentials lookup failure → broker=null graceful degrade."""
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-1", permission="read",
|
|
token_type="service", runtime_id="rt-1", daemon_id="daemon-9",
|
|
)
|
|
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.side_effect = RuntimeError(
|
|
'relation "broker_credentials" does not exist'
|
|
)
|
|
|
|
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["daemon_id"] == "daemon-9"
|
|
assert body["user_id"] == "user-1"
|
|
# REQ-049 AC-2: broker=null (graceful degrade, 크래시 없음)
|
|
assert body["broker"] is None
|
|
assert "brokers" not in body
|
|
|
|
|
|
def test_v1_user_info_inactive_user_has_disabled_reason_b(monkeypatch) -> None:
|
|
# daemon_id 있음 + 비활성 user
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
|
|
|
principal = TokenPrincipal(
|
|
token_id="t1", user_id="user-2", permission="read",
|
|
token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2",
|
|
)
|
|
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()
|
|
resp = client.get("/v1/user_info", headers=_service_token_header())
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["disabled_reason"] == "account_inactive"
|