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:
@@ -12,6 +12,8 @@ from app.schemas.auth import (
|
||||
AuthTokenResponse,
|
||||
CreateClientTokenRequest,
|
||||
IssuedClientTokenResponse,
|
||||
IssueServiceTokenRequest,
|
||||
IssueServiceTokenApiResponse,
|
||||
LoginRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
@@ -118,3 +120,25 @@ def revoke_token(
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/service-tokens", response_model=IssueServiceTokenApiResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)])
|
||||
def issue_service_token(
|
||||
payload: IssueServiceTokenRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> IssueServiceTokenApiResponse:
|
||||
try:
|
||||
result = service.issue_service_token(
|
||||
payload.name,
|
||||
payload.username,
|
||||
permission=payload.permission,
|
||||
expires_in_days=payload.expires_in_days,
|
||||
no_expiry=payload.no_expiry,
|
||||
)
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
return IssueServiceTokenApiResponse(
|
||||
token=result.token,
|
||||
runtime_id=result.auth_token.runtime_id,
|
||||
auth_token=_to_auth_token_response(result.auth_token),
|
||||
)
|
||||
+58
-24
@@ -2,13 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.repositories.portfolio_repository import PortfolioRepository
|
||||
from app.schemas.v1 import AssetItem, AssetsResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
@@ -21,8 +25,21 @@ def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthCon
|
||||
return auth
|
||||
|
||||
|
||||
def _parse_strategy_params(strategy_set_config: str | None) -> dict:
|
||||
if not strategy_set_config:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(strategy_set_config)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/assets", response_model=AssetsResponse)
|
||||
def list_assets(auth: AuthContext = Depends(_require_service_token)) -> AssetsResponse:
|
||||
def list_assets(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
auth: AuthContext = Depends(_require_service_token),
|
||||
) -> AssetsResponse:
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
@@ -32,30 +49,47 @@ def list_assets(auth: AuthContext = Depends(_require_service_token)) -> AssetsRe
|
||||
owner_user_id=None,
|
||||
include_disabled=False,
|
||||
)
|
||||
|
||||
# Fetch strategy_params per portfolio
|
||||
items = []
|
||||
for p in portfolios:
|
||||
# Optional status filter
|
||||
if status_filter is not None and p.status != status_filter:
|
||||
continue
|
||||
|
||||
strategy_params: dict = {}
|
||||
if p.active_strategy_id:
|
||||
try:
|
||||
ss = repo.get_strategy_set(p.active_strategy_id)
|
||||
if ss is not None:
|
||||
strategy_params = _parse_strategy_params(ss.config)
|
||||
except Exception:
|
||||
logger.warning("strategy_set lookup failed for %s", p.active_strategy_id)
|
||||
strategy_params = {}
|
||||
|
||||
items.append(
|
||||
AssetItem(
|
||||
asset_id=p.portfolio_id,
|
||||
symbol=p.symbol,
|
||||
market=p.market,
|
||||
status=p.status,
|
||||
cash=p.cash_balance,
|
||||
quantity=p.asset_quantity,
|
||||
broker_type=p.broker,
|
||||
strategy_id=p.active_strategy_id,
|
||||
strategy_params=strategy_params,
|
||||
updated_at=p.updated_at,
|
||||
name=p.name,
|
||||
price=p.price,
|
||||
profit_buffer=p.profit_buffer,
|
||||
tags=p.tags,
|
||||
owner_user_id=p.owner_user_id,
|
||||
created_at=p.created_at,
|
||||
last_trade_at=p.last_trade_at,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
items = [
|
||||
AssetItem(
|
||||
asset_id=p.portfolio_id,
|
||||
broker=p.broker,
|
||||
market=p.market,
|
||||
symbol=p.symbol,
|
||||
name=p.name,
|
||||
status=p.status,
|
||||
cash_balance=p.cash_balance,
|
||||
asset_quantity=p.asset_quantity,
|
||||
price=p.price,
|
||||
profit_buffer=p.profit_buffer,
|
||||
active_strategy_id=p.active_strategy_id,
|
||||
available_strategies=p.avabile_strategies,
|
||||
tags=p.tags,
|
||||
owner_user_id=p.owner_user_id,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
last_trade_at=p.last_trade_at,
|
||||
)
|
||||
for p in portfolios
|
||||
]
|
||||
return AssetsResponse(assets=items, total=len(items))
|
||||
|
||||
+38
-2
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
@@ -20,6 +22,17 @@ def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthCon
|
||||
return auth
|
||||
|
||||
|
||||
def _payloads_match(existing, body: LedgerTradeRequest) -> bool:
|
||||
"""Return True when the existing trade's payload matches the request body."""
|
||||
return (
|
||||
existing.side == body.side
|
||||
and abs(existing.quantity - float(body.quantity)) < 1e-9
|
||||
and abs(existing.price - float(body.price)) < 1e-9
|
||||
and abs(existing.fee_amount - float(body.fee_amount)) < 1e-9
|
||||
and abs(existing.tax_amount - float(body.tax_amount)) < 1e-9
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/assets/{asset_id}/ledger/trades",
|
||||
response_model=LedgerTradeResponse,
|
||||
@@ -28,6 +41,7 @@ def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthCon
|
||||
def record_ledger_trade(
|
||||
asset_id: str,
|
||||
body: LedgerTradeRequest,
|
||||
response: Response,
|
||||
auth: AuthContext = Depends(_require_service_token),
|
||||
) -> LedgerTradeResponse:
|
||||
from app.core.config import get_settings
|
||||
@@ -35,6 +49,15 @@ def record_ledger_trade(
|
||||
settings = get_settings()
|
||||
repo = get_portfolio_repository_from_settings(settings)
|
||||
try:
|
||||
# B-9: Validate asset_id (portfolio) existence
|
||||
try:
|
||||
repo.get(asset_id)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Asset not found: {asset_id}",
|
||||
)
|
||||
|
||||
trade, created = repo.insert_ledger_trade(
|
||||
asset_id=asset_id,
|
||||
order_id=body.order_id,
|
||||
@@ -50,6 +73,19 @@ def record_ledger_trade(
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
if not created:
|
||||
# B-8: Check payload conflict → 409
|
||||
if not _payloads_match(trade, body):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Duplicate key with different payload",
|
||||
)
|
||||
# B-7: Same payload duplicate → 200 + status="duplicate"
|
||||
response.status_code = status.HTTP_200_OK
|
||||
trade_status = "duplicate"
|
||||
else:
|
||||
trade_status = "new"
|
||||
|
||||
return LedgerTradeResponse(
|
||||
ledger_entry_id=trade.ledger_entry_id,
|
||||
asset_id=trade.asset_id,
|
||||
@@ -62,5 +98,5 @@ def record_ledger_trade(
|
||||
tax_amount=trade.tax_amount,
|
||||
executed_at=trade.executed_at,
|
||||
recorded_at=trade.recorded_at,
|
||||
created=created,
|
||||
status=trade_status,
|
||||
)
|
||||
|
||||
+41
-8
@@ -2,12 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.encryption import InvalidToken, decrypt_payload
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import UserInfoResponse
|
||||
from app.schemas.v1 import BrokerItem, UserInfoResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
@@ -21,19 +27,45 @@ def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthCon
|
||||
|
||||
|
||||
@router.get("/user_info", response_model=UserInfoResponse)
|
||||
def get_user_info(auth: AuthContext = Depends(_require_service_token)) -> UserInfoResponse:
|
||||
def get_user_info(
|
||||
auth: AuthContext = Depends(_require_service_token),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> UserInfoResponse:
|
||||
if not auth.runtime_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="runtime_id not assigned to this token",
|
||||
)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
repo = get_portfolio_repository_from_settings(settings)
|
||||
try:
|
||||
user = repo.get_user(auth.user_id) # type: ignore[arg-type]
|
||||
|
||||
# Fetch broker credentials and decrypt
|
||||
brokers: list[BrokerItem] = []
|
||||
try:
|
||||
credentials = repo.list_broker_credentials(auth.user_id) # type: ignore[arg-type]
|
||||
enc_key: str | None = None
|
||||
for cred in credentials:
|
||||
credential_payload = None
|
||||
try:
|
||||
if enc_key is None:
|
||||
enc_key = settings.require_credential_encryption_key()
|
||||
raw = decrypt_payload(enc_key, cred.encrypted_payload)
|
||||
credential_payload = json.loads(raw)
|
||||
except (InvalidToken, RuntimeError, Exception) as exc:
|
||||
logger.warning("credential decryption failed for broker %s: %s", cred.broker, exc)
|
||||
brokers.append(BrokerItem(
|
||||
broker=cred.broker,
|
||||
account_ref=cred.account_ref,
|
||||
credential_payload=credential_payload,
|
||||
credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at,
|
||||
))
|
||||
except AttributeError:
|
||||
# repo does not implement list_broker_credentials yet (mock or older impl)
|
||||
pass
|
||||
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
@@ -43,11 +75,12 @@ def get_user_info(auth: AuthContext = Depends(_require_service_token)) -> UserIn
|
||||
disabled_reason = "account_inactive"
|
||||
|
||||
return UserInfoResponse(
|
||||
runtime_id=auth.runtime_id,
|
||||
trading_enabled=user.is_active,
|
||||
disabled_reason=disabled_reason,
|
||||
brokers=brokers,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
permission=user.permission,
|
||||
is_active=user.is_active,
|
||||
disabled_reason=disabled_reason,
|
||||
runtime_id=auth.runtime_id,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,8 @@ def create_app(
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
_bootstrap_initial_user(settings, repository_factory)
|
||||
if settings.db_enabled:
|
||||
settings.require_credential_encryption_key()
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
@@ -1334,6 +1334,20 @@ class PostgresPortfolioRepository:
|
||||
return None
|
||||
return self._row_to_broker_credential(row)
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
FROM broker_credentials
|
||||
WHERE user_id = %s
|
||||
ORDER BY broker ASC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_broker_credential(row) for row in rows]
|
||||
|
||||
def upsert_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@@ -70,6 +70,21 @@ class IssuedServiceToken(BaseModel):
|
||||
auth_token: AuthTokenResponse
|
||||
|
||||
|
||||
class IssueServiceTokenRequest(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
username: str = Field(min_length=1)
|
||||
permission: Literal["read", "trade-write", "admin"] = "read"
|
||||
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
no_expiry: bool = False
|
||||
|
||||
|
||||
class IssueServiceTokenApiResponse(BaseModel):
|
||||
token: str
|
||||
token_type: Literal["bearer"] = "bearer"
|
||||
runtime_id: str | None = None
|
||||
auth_token: AuthTokenResponse
|
||||
|
||||
|
||||
class ServiceTokenIssueCliResponse(BaseModel):
|
||||
token_id: str
|
||||
name: str
|
||||
|
||||
+26
-15
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -15,21 +15,22 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class AssetItem(BaseModel):
|
||||
asset_id: str
|
||||
broker: str
|
||||
market: str | None = None
|
||||
symbol: str
|
||||
name: str
|
||||
market: str | None = None
|
||||
status: str
|
||||
cash_balance: float
|
||||
asset_quantity: float
|
||||
price: float
|
||||
profit_buffer: float
|
||||
active_strategy_id: str | None = None
|
||||
available_strategies: list[str] | None = None
|
||||
cash: float
|
||||
quantity: float
|
||||
broker_type: str
|
||||
strategy_id: str | None = None
|
||||
strategy_params: dict[str, Any] = {}
|
||||
updated_at: datetime | None = None
|
||||
# Additional informational fields
|
||||
name: str | None = None
|
||||
price: float | None = None
|
||||
profit_buffer: float | None = None
|
||||
tags: list[str] | None = None
|
||||
owner_user_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
last_trade_at: datetime | None = None
|
||||
|
||||
|
||||
@@ -42,14 +43,24 @@ class AssetsResponse(BaseModel):
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BrokerItem(BaseModel):
|
||||
broker: str
|
||||
account_ref: str
|
||||
credential_payload: dict[str, Any] | None = None
|
||||
credential_version: int = 1
|
||||
last_rotated_at: datetime | None = None
|
||||
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
runtime_id: str
|
||||
trading_enabled: bool
|
||||
disabled_reason: str | None = None
|
||||
brokers: list[BrokerItem] = []
|
||||
# Additional informational fields
|
||||
user_id: str
|
||||
username: str
|
||||
email: str | None = None
|
||||
permission: str
|
||||
is_active: bool
|
||||
disabled_reason: str | None = None
|
||||
runtime_id: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -79,4 +90,4 @@ class LedgerTradeResponse(BaseModel):
|
||||
tax_amount: float
|
||||
executed_at: datetime
|
||||
recorded_at: datetime | None = None
|
||||
created: bool
|
||||
status: Literal["new", "duplicate"] = "new"
|
||||
|
||||
@@ -59,16 +59,52 @@ design.md에서 확정된 결정 사항을 기반으로 아래 범위를 구현
|
||||
11. [x] app/api/v1_ledger.py 구현 (idempotent)
|
||||
12. [x] app/repositories/postgres.py 쿼리 추가
|
||||
13. [x] app/main.py /v1 라우터 등록
|
||||
14. [x] 테스트 작성 및 전체 테스트 스위트 통과 (88 passed)
|
||||
14. [x] 테스트 작성 및 전체 테스트 스위트 통과 (98 passed, 2026-05-01 blocker 수정 후)
|
||||
|
||||
## Validation
|
||||
|
||||
- [x] pytest 88개 전체 통과 (2026-05-02)
|
||||
- [x] pytest 98개 전체 통과 (2026-05-01, blocker B-1~B-12 수정 후)
|
||||
- [ ] alembic upgrade head 성공 (로컬 Docker Compose 환경 — DB 없이 코드 검증만 완료)
|
||||
- [x] GET /health 응답에 service/version/checked_at(+09:00 KST) 포함 확인 (테스트)
|
||||
- [x] GET /v1/assets 200 응답 확인 (테스트)
|
||||
- [x] GET /v1/assets 200 응답, 필드명 확인 (cash/quantity/broker_type/strategy_params)
|
||||
- [x] GET /v1/assets ?status 필터 확인 (테스트)
|
||||
- [x] GET /v1/user_info runtime_id 없는 토큰 → 404 확인 (테스트)
|
||||
- [x] POST /v1/assets/{asset_id}/ledger/trades 중복 → created=false 확인 (테스트)
|
||||
- [x] GET /v1/user_info trading_enabled, brokers[] 확인 (테스트)
|
||||
- [x] GET /v1/user_info Fernet 복호화 → credential_payload 반환 확인 (테스트)
|
||||
- [x] POST /v1/assets/{asset_id}/ledger/trades: new → 201, duplicate same payload → 200, different payload → 409, unknown asset → 404 (테스트)
|
||||
- [x] POST /auth/service-tokens → 201 + runtime_id 확인 (테스트)
|
||||
- [x] startup: CREDENTIAL_ENCRYPTION_KEY 없으면 RuntimeError (테스트)
|
||||
- [x] Fernet encrypt/decrypt roundtrip 확인 (테스트)
|
||||
|
||||
## Blocker Fixes (2026-05-01)
|
||||
|
||||
- B-1: POST /auth/service-tokens 엔드포인트 추가 (app/api/auth_routes.py, app/schemas/auth.py)
|
||||
- B-2: GET /v1/assets ?status 필터 추가 (app/api/v1_assets.py)
|
||||
- B-3: 응답 필드명 수정 (cash/quantity/broker_type/strategy_id → 계약 정의명)
|
||||
- B-4: strategy_params {} 폴백 구현
|
||||
- B-5: UserInfoResponse trading_enabled 필드 추가
|
||||
- B-6: brokers[] 배열 구현 + Fernet 복호화
|
||||
- B-7: 중복 수신 → HTTP 200 + status="duplicate" (was created: bool)
|
||||
- B-8: 다른 payload → HTTP 409
|
||||
- B-9: 없는 asset_id → HTTP 404
|
||||
- B-10: lifespan startup validation (CREDENTIAL_ENCRYPTION_KEY 없으면 RuntimeError)
|
||||
- B-11: list_broker_credentials postgres 구현
|
||||
- B-12: docs/project-structure.md, docs/runtime-flows.md에 신규 모듈 및 v1 흐름 반영
|
||||
|
||||
## Changed Files (Blocker Fixes)
|
||||
|
||||
- app/api/auth_routes.py (수정: POST /auth/service-tokens 엔드포인트 추가)
|
||||
- app/schemas/auth.py (수정: IssueServiceTokenRequest, IssueServiceTokenApiResponse 추가)
|
||||
- app/schemas/v1.py (수정: 필드명 전면 수정, BrokerItem, trading_enabled, status)
|
||||
- app/api/v1_assets.py (수정: status 필터, strategy_params, 새 필드명)
|
||||
- app/api/v1_user_info.py (수정: trading_enabled, brokers[], Fernet 복호화, Depends(get_settings))
|
||||
- app/api/v1_ledger.py (수정: 200/201/404/409 분기, _payloads_match 비교)
|
||||
- app/repositories/postgres.py (수정: list_broker_credentials 추가)
|
||||
- app/main.py (수정: startup validation 추가)
|
||||
- tests/test_v1.py (전면 재작성: 19개 테스트로 확장)
|
||||
- tests/test_app.py (수정: db_enabled=True 테스트에 credential_encryption_key 추가)
|
||||
- docs/project-structure.md (수정: v1 모듈, encryption.py 반영)
|
||||
- docs/runtime-flows.md (수정: v1 API 흐름, startup 검증 흐름 추가)
|
||||
|
||||
## Design Decisions (confirmed during implementation)
|
||||
|
||||
|
||||
@@ -1,7 +1,172 @@
|
||||
# BAT-001 Verification
|
||||
|
||||
- Status: draft
|
||||
- Status: verified
|
||||
- Batch: BAT-001
|
||||
- Last Updated (KST): 2026-05-01
|
||||
|
||||
## Acceptance Mapping
|
||||
|
||||
### REQ-004: GET /health 응답 스키마 확장
|
||||
|
||||
| # | Acceptance Criterion | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC1 | status/service/version/checked_at 필드 모두 존재 | `tests/test_v1.py::test_health_returns_full_response` — 4개 필드 전체 assert | PASS |
|
||||
| AC2 | status 허용값 ok/degraded/failed | test에서 "ok" 값만 검증. "degraded"/"failed" 경로는 이번 반복 OOS로 REQ Notes에 명시 | PARTIAL (OOS 인정) |
|
||||
| AC3 | checked_at ISO 8601 형식 | `test_v1.py::test_health_returns_full_response` — `"+09:00" in body["checked_at"]` assert | PASS |
|
||||
| AC4 | service 고정값 "portfolio-hub" | `test_v1.py` — `body["service"] == "portfolio-hub"` | PASS |
|
||||
| AC5 | 비인증 접근 200 | `test_v1.py::test_health_returns_full_response` — 인증 헤더 없이 GET /health 호출, 200 응답 | PASS |
|
||||
|
||||
**REQ-004 판정: PASS** (AC2는 이번 반복 OOS — 릴리스 블로커 아님)
|
||||
|
||||
---
|
||||
|
||||
### REQ-005: service-token 발급 시 runtime_id 동시 생성
|
||||
|
||||
| # | Acceptance Criterion | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC1 | CLI 출력에 token + runtime_id | `tests/test_cli.py::test_cli_service_token_issue_supports_json` PASS | PASS |
|
||||
| AC2 | API 엔드포인트 응답에 token + runtime_id | `tests/test_v1.py::test_auth_issue_service_token_returns_runtime_id` — POST /auth/service-tokens → 201 + `runtime_id` PASS | PASS |
|
||||
| AC3 | GET /v1/user_info 응답 runtime_id 일치 | `tests/test_v1.py::test_v1_user_info_returns_user` — `body["runtime_id"] == "rt-abc"` PASS | PASS |
|
||||
| AC4 | 두 번 발급 시 다른 runtime_id | `auth_service.py`에서 매 발급마다 `str(uuid.uuid4())` 생성 — 코드 검토 PASS | PASS |
|
||||
| AC5 | auth_tokens 테이블 runtime_id 저장 | `postgres.py::create_auth_token` INSERT에 runtime_id 포함 — 코드 검토 PASS. DB 환경 실행 미확인(PARTIAL) | PARTIAL (DB 미확인) |
|
||||
|
||||
**REQ-005 판정: PASS** (AC5 DB 미확인은 REQ-009와 동일 제약으로 릴리스 블로커 아님)
|
||||
|
||||
---
|
||||
|
||||
### REQ-006: GET /v1/assets 엔드포인트
|
||||
|
||||
| # | Acceptance Criterion | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC1 | service-token 인증 → 200 + portfolio 목록 | `tests/test_v1.py::test_v1_assets_returns_asset_list` PASS | PASS |
|
||||
| AC2 | ?status=active 쿼리 파라미터 필터 | `tests/test_v1.py::test_v1_assets_status_filter` — ?status=active 필터 동작 확인 PASS | PASS |
|
||||
| AC3 | 응답 필드: asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_params, updated_at | `test_v1_assets_returns_asset_list` — cash/quantity/broker_type/strategy_params 필드명 assert PASS | PASS |
|
||||
| AC4 | strategy_params → StrategySet.config 파생 JSON | `tests/test_v1.py::test_v1_assets_strategy_params` — config JSON 파싱 결과 반환 PASS | PASS |
|
||||
| AC5 | strategy_params {} 폴백 | `tests/test_v1.py::test_v1_assets_strategy_params_fallback_on_error` — 조회 실패 시 {} 반환 PASS | PASS |
|
||||
| AC6 | portfolios.market NULL → 응답 null | `AssetItem.market: str | None = None`, `Portfolio.market: str | None = None` — NULL 전파 구조 검토 PASS | PASS |
|
||||
| AC7 | 미인증 401 | `tests/test_v1.py::test_v1_assets_requires_service_token` PASS | PASS |
|
||||
|
||||
**REQ-006 판정: PASS**
|
||||
|
||||
---
|
||||
|
||||
### REQ-007: GET /v1/user_info 엔드포인트
|
||||
|
||||
| # | Acceptance Criterion | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC1 | 응답에 runtime_id, trading_enabled, brokers[] 포함 | `tests/test_v1.py::test_v1_user_info_returns_user` — 3개 필드 모두 assert PASS | PASS |
|
||||
| AC2 | environment 필드 없음 | `UserInfoResponse` 스키마 검토 — environment 필드 없음 PASS | PASS |
|
||||
| AC3 | is_active=true → trading_enabled=true | `test_v1_user_info_returns_user` — `body["trading_enabled"] is True` PASS | PASS |
|
||||
| AC4 | is_active=false → trading_enabled=false, disabled_reason 포함 | `tests/test_v1.py::test_v1_user_info_inactive_user_has_disabled_reason` — `trading_enabled is False`, `disabled_reason == "account_inactive"` PASS | PASS |
|
||||
| AC5 | brokers[]에 credential_payload 복호화 JSON | `tests/test_v1.py::test_v1_user_info_returns_decrypted_broker_credentials` — Fernet 복호화 payload 반환 PASS | PASS |
|
||||
| AC6 | runtime_id 없는 구형 토큰 → HTTP 404 | `tests/test_v1.py::test_v1_user_info_404_when_no_runtime_id` — 404 + detail PASS | PASS |
|
||||
| AC7 | 미인증 401 | `_require_service_token` 의존성 + `test_v1_assets_requires_service_token` 패턴으로 구조 검토 PASS | PASS |
|
||||
|
||||
**REQ-007 판정: PASS**
|
||||
|
||||
---
|
||||
|
||||
### REQ-008: POST /v1/assets/{asset_id}/ledger/trades
|
||||
|
||||
| # | Acceptance Criterion | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC1 | 신규 체결 → HTTP 201 + ledger_entry_id | `tests/test_v1.py::test_v1_ledger_trade_created` — 201 + `status="new"` PASS | PASS |
|
||||
| AC2 | 동일 payload 재전송 → HTTP 200 + status="duplicate" | `tests/test_v1.py::test_v1_ledger_trade_idempotent_same_payload` — 200 + `status="duplicate"` PASS | PASS |
|
||||
| AC3 | 동일 키 다른 payload → HTTP 409 | `tests/test_v1.py::test_v1_ledger_trade_conflict_different_payload` — 409 PASS | PASS |
|
||||
| AC4 | 존재하지 않는 asset_id → 404 | `tests/test_v1.py::test_v1_ledger_trade_asset_not_found` — 404 PASS | PASS |
|
||||
| AC5 | 미인증 401 | `_require_service_token` 의존성 구조 검토 PASS | PASS |
|
||||
|
||||
**REQ-008 판정: PASS**
|
||||
|
||||
---
|
||||
|
||||
### REQ-009: v1 API DB 스키마 migration
|
||||
|
||||
| # | Acceptance Criterion | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC1 | portfolios.market 컬럼 존재 | `20260502_0014_v1_api_schema.py` — IF NOT EXISTS 패턴 구현. 코드 검토 PASS. DB 환경 미확인 | PARTIAL |
|
||||
| AC2 | auth_tokens.runtime_id 컬럼 존재 | 동일 migration — UNIQUE INDEX 포함. 코드 검토 PASS | PARTIAL |
|
||||
| AC3 | broker_credentials 테이블 존재 | 동일 migration — PK(user_id, broker). 코드 검토 PASS | PARTIAL |
|
||||
| AC4 | ledger_trades 테이블 존재 | 동일 migration — BIGSERIAL PK. 코드 검토 PASS | PARTIAL |
|
||||
| AC5 | (asset_id, order_id, execution_id) UniqueViolation | `uq_ledger_trades_idempotency` UniqueConstraint 정의 확인. DB 실행 미확인 | PARTIAL |
|
||||
| AC6 | 재실행 시 에러 없음 | IF NOT EXISTS + `_table_exists`/`_column_exists` 패턴. 코드 검토 PASS | PARTIAL |
|
||||
| AC7 | 기존 행 market NULL 유지 | ADD COLUMN NULLABLE, 기존 행 UPDATE 없음. 코드 검토 PASS | PARTIAL |
|
||||
|
||||
**REQ-009 판정: PARTIAL** — 코드 전체 PASS. 실제 DB 환경(Docker Compose) 실행 검증 미수행. 코드 전용 환경에서는 블로커 아님.
|
||||
|
||||
---
|
||||
|
||||
### REQ-010: credential_payload Fernet 암호화 및 startup validation
|
||||
|
||||
| # | Acceptance Criterion | Evidence | Result |
|
||||
|---|---|---|---|
|
||||
| AC1 | KEY 있음 → 정상 기동 | `tests/test_v1.py::test_startup_ok_when_encryption_key_present` PASS | PASS |
|
||||
| AC2 | KEY 없음 → 기동 실패 + 에러 메시지 | `tests/test_v1.py::test_startup_fails_when_encryption_key_missing` — db_enabled=True + KEY 없음 → RuntimeError("CREDENTIAL_ENCRYPTION_KEY") PASS | PASS |
|
||||
| AC3 | DB에 ciphertext 저장 | `tests/test_v1.py::test_encrypt_decrypt_roundtrip` — encrypt→decrypt roundtrip PASS. 저장 경로는 `upsert_broker_credential` 메서드에서 암호화된 payload 직접 수신 (호출자 책임). DB 환경 실행 미확인 | PARTIAL |
|
||||
| AC4 | GET /v1/user_info 복호화 JSON 반환 | `tests/test_v1.py::test_v1_user_info_returns_decrypted_broker_credentials` PASS | PASS |
|
||||
| AC5 | 잘못된 키 → InvalidToken 예외 처리 | `encryption.py`에서 `InvalidToken` re-export. `v1_user_info.py`에서 except 처리 후 `credential_payload: null` 반환 — 코드 검토 PASS | PASS |
|
||||
|
||||
**REQ-010 판정: PASS** (AC3 DB 미확인은 REQ-009와 동일 제약)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Impact & Baseline Docs Sync
|
||||
|
||||
- `docs/project-structure.md` — 갱신 완료: `app/core/encryption.py`, v1 라우터 모듈, v1 스키마 반영
|
||||
- `docs/runtime-flows.md` — 갱신 완료: v1 API 요청 흐름 5개, startup 검증 흐름 추가
|
||||
|
||||
**판정: PASS**
|
||||
|
||||
---
|
||||
|
||||
## Evidence
|
||||
|
||||
### Automated Tests (pytest)
|
||||
|
||||
- `tests/test_v1.py` — 19개 전부 PASS
|
||||
- `test_health_returns_full_response`, `test_auth_issue_service_token_returns_runtime_id`
|
||||
- `test_v1_assets_requires_service_token`, `test_v1_assets_rejects_env_token`, `test_v1_assets_returns_asset_list`
|
||||
- `test_v1_assets_status_filter`, `test_v1_assets_strategy_params`, `test_v1_assets_strategy_params_fallback_on_error`
|
||||
- `test_v1_user_info_404_when_no_runtime_id`, `test_v1_user_info_returns_user`, `test_v1_user_info_inactive_user_has_disabled_reason`
|
||||
- `test_v1_user_info_returns_decrypted_broker_credentials`
|
||||
- `test_v1_ledger_trade_created`, `test_v1_ledger_trade_idempotent_same_payload`, `test_v1_ledger_trade_conflict_different_payload`, `test_v1_ledger_trade_asset_not_found`
|
||||
- `test_startup_fails_when_encryption_key_missing`, `test_startup_ok_when_encryption_key_present`, `test_encrypt_decrypt_roundtrip`
|
||||
- `tests/test_app.py` — PASS (startup bootstrap 포함)
|
||||
- `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_domain_services.py` — PASS
|
||||
- `tests/test_portfolio_comments.py`, `tests/test_portfolio_ownership.py`, `tests/test_portfolio_tags.py`, `tests/test_users.py` — PASS
|
||||
- **전체 합계: 98 passed (2026-05-01)**
|
||||
|
||||
### Code Review
|
||||
|
||||
- `app/core/encryption.py`: Fernet 암복호화 유틸리티 정상 구현
|
||||
- `app/api/auth_routes.py`: `POST /auth/service-tokens` 엔드포인트 추가
|
||||
- `app/api/v1_assets.py`: status 필터, strategy_params 조회, 새 필드명 매핑
|
||||
- `app/api/v1_user_info.py`: trading_enabled, brokers[], Fernet 복호화, Depends(get_settings)
|
||||
- `app/api/v1_ledger.py`: 200/201/404/409 분기, _payloads_match 비교
|
||||
- `app/repositories/postgres.py`: list_broker_credentials 추가
|
||||
- `app/main.py`: startup validation (CREDENTIAL_ENCRYPTION_KEY)
|
||||
|
||||
### DB 환경 실행
|
||||
|
||||
- 미수행 (Docker Compose 환경 미기동). 코드 전용 환경에서는 블로커 아님.
|
||||
|
||||
---
|
||||
|
||||
## Blocking Issues
|
||||
|
||||
없음.
|
||||
|
||||
## Non-blocking Notes
|
||||
|
||||
- REQ-009 AC1~AC7: 코드 검토 PASS, 실제 DB 환경 실행 미확인. 다음 릴리스 전 live DB 검증 권장.
|
||||
- REQ-004 AC2: "degraded"/"failed" 상태 경로는 이번 반복 OOS.
|
||||
|
||||
---
|
||||
|
||||
## Result
|
||||
|
||||
- **Ready for Release: true**
|
||||
|
||||
|
||||
## Acceptance Mapping
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
|
||||
| BAT ID | Profile | Status | Included REQ | Discovery | Folder |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| BAT-001 | standard | in-delivery | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) |
|
||||
| BAT-001 | standard | release-candidate | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) |
|
||||
@@ -4,8 +4,8 @@
|
||||
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
|
||||
- Status: confirmed
|
||||
- Owner: repository-maintainers
|
||||
- Last Updated (KST): 2026-04-30 22:13
|
||||
- Source Discovery / Batch: bootstrap-baseline
|
||||
- Last Updated (KST): 2026-05-01 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001
|
||||
|
||||
## Project Summary
|
||||
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
|
||||
@@ -83,16 +83,20 @@
|
||||
- Hosts StagePilot skills, document templates, and helper scripts such as stagepilot-doctor and stagepilot-bootstrap-seed.
|
||||
- app/api/
|
||||
- Defines FastAPI route surfaces for health, authentication, users, strategy sets, and portfolios.
|
||||
- v1 route surfaces: `v1_assets.py` (GET /v1/assets), `v1_user_info.py` (GET /v1/user_info), `v1_ledger.py` (POST /v1/assets/{asset_id}/ledger/trades).
|
||||
- All v1 endpoints require service-token authentication (token_source=db, token_type=service).
|
||||
- app/cli/
|
||||
- Defines the Typer entrypoint, command groups, output helpers, and operator-facing command flows.
|
||||
- app/core/
|
||||
- Owns configuration loading, auth guards, security helpers, and shared error handling.
|
||||
- `encryption.py`: Fernet-based credential payload encrypt/decrypt helpers. Reads `CREDENTIAL_ENCRYPTION_KEY` env var via `Settings.require_credential_encryption_key()`.
|
||||
- app/domain/
|
||||
- Holds framework-light domain models shared by services, routes, and repositories.
|
||||
- app/repositories/
|
||||
- Provides the repository protocol, repository factory, and concrete Postgres persistence implementation.
|
||||
- app/schemas/
|
||||
- Defines request and response schemas for HTTP contracts.
|
||||
- `v1.py`: Contract schemas for v1 API (AssetItem, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response).
|
||||
- app/services/
|
||||
- Implements business logic for auth, portfolios, strategy sets, and users.
|
||||
- migrations/
|
||||
@@ -119,9 +123,9 @@
|
||||
|
||||
## Current Gaps / Planned Changes
|
||||
|
||||
- No active Discovery, REQ, Batch, or Release units exist yet; this baseline only bootstraps the document structure.
|
||||
- The non-DB repository path intentionally raises a feature-not-ready error when persistence is disabled.
|
||||
- Redis and Loki settings exist, but their runtime ownership and module boundaries are not yet represented in active docs.
|
||||
- Broker credential write path (upsert) is available only via CLI; no public HTTP API exists for credential registration yet.
|
||||
|
||||
## Update Triggers
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
|
||||
| REL ID | Profile | Status | Included Batch | Discovery/REQ Input | Link |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| REL-001 | app-service | draft | BAT-001 | dcy-002 / REQ-004~010 | [rel-001_20260502_trex-basic-v1-api-contract](./rel-001_20260502_trex-basic-v1-api-contract.md) |
|
||||
@@ -0,0 +1,119 @@
|
||||
# REL-001: trex-basic v1 API contract
|
||||
|
||||
- Status: draft
|
||||
- Profile: app-service
|
||||
- Included Batch: BAT-001
|
||||
|
||||
## Scope
|
||||
|
||||
- Included:
|
||||
- BAT-001 (REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010)
|
||||
- GET /health 응답 스키마 확장 (service/version/checked_at 필드)
|
||||
- POST /auth/service-tokens — service-token 발급 + runtime_id 반환 API
|
||||
- GET /v1/assets — 포트폴리오 목록 조회 (status 필터, strategy_params)
|
||||
- GET /v1/user_info — 사용자 정보 + broker credentials 복호화 반환
|
||||
- POST /v1/assets/{asset_id}/ledger/trades — 체결 기록 (idempotent: 201/200/409/404)
|
||||
- Fernet 암호화 유틸리티 (app/core/encryption.py)
|
||||
- startup validation: CREDENTIAL_ENCRYPTION_KEY 없으면 RuntimeError
|
||||
- DB 스키마 migration: portfolios.market, auth_tokens.runtime_id, broker_credentials, ledger_trades
|
||||
|
||||
- Excluded:
|
||||
- GET /health status degraded/failed 경로 (이번 반복 OOS)
|
||||
- broker credential 저장 HTTP API (CLI 전용)
|
||||
- DB 환경(Docker Compose) 직접 실행 검증
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. 배포 전 `CREDENTIAL_ENCRYPTION_KEY` 환경 변수 설정 확인 (`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 로 생성)
|
||||
2. `alembic upgrade head` 실행 — migration 0014 적용 (portfolios.market, auth_tokens.runtime_id, broker_credentials, ledger_trades)
|
||||
3. 서비스 재시작 (`uvicorn app.main:app`)
|
||||
4. GET /health 호출로 기동 확인
|
||||
5. v1 smoke 테스트: GET /v1/assets, GET /v1/user_info (service-token 사용)
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- 조건: startup 실패 또는 GET /health 비정상 응답 시
|
||||
- 절차:
|
||||
1. 이전 버전 코드로 롤백 (`git revert` 또는 이전 컨테이너 이미지 재기동)
|
||||
2. `alembic downgrade -1` — migration 0014 롤백 (신규 테이블/컬럼 DROP)
|
||||
3. `CREDENTIAL_ENCRYPTION_KEY` 미설정 상태로도 DB disabled 모드에서는 기동 가능하므로, 긴급 시 `PORTFOLIOHUB_DB_ENABLED=false`로 임시 운영 가능
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### 배포 전
|
||||
|
||||
- [ ] `CREDENTIAL_ENCRYPTION_KEY` 환경 변수 설정 완료
|
||||
- [ ] `alembic upgrade head` 성공 (migration 0014)
|
||||
- [ ] pytest 98개 로컬 PASS 확인 (2026-05-01 완료)
|
||||
|
||||
### 배포 후
|
||||
|
||||
- [ ] GET /health → 200, body에 `status`, `service`, `version`, `checked_at` 포함 확인
|
||||
- [ ] GET /health → `checked_at`에 `+09:00` 포함 확인
|
||||
- [ ] POST /auth/service-tokens (admin 토큰) → 201, `runtime_id` 포함 확인
|
||||
- [ ] GET /v1/assets (service-token) → 200, `assets[]` 반환 확인
|
||||
- [ ] GET /v1/user_info (service-token) → 200, `trading_enabled`/`brokers[]` 포함 확인
|
||||
- [ ] POST /v1/assets/{asset_id}/ledger/trades → 201 (신규), 200 (중복 동일 payload) 확인
|
||||
- [ ] 서비스 startup 로그에 RuntimeError 없음 확인
|
||||
|
||||
## Release Execution Evidence
|
||||
|
||||
- Release Status Transition Target: released
|
||||
|
||||
### Deployment Facts
|
||||
|
||||
- Executed At:
|
||||
- (미기록 — 배포 미수행)
|
||||
- Completed At:
|
||||
- (미기록)
|
||||
- Environment:
|
||||
- (미기록)
|
||||
- Operator:
|
||||
- (미기록)
|
||||
- Revision or Artifact:
|
||||
- (미기록 — 커밋 SHA 배포 후 기록)
|
||||
- Commands or Procedure:
|
||||
- (미기록)
|
||||
|
||||
### Profile-Specific Checks
|
||||
|
||||
- app-service:
|
||||
- Health Endpoint:
|
||||
- (미수행 — 배포 후 확인 필요)
|
||||
- Startup Log Fields:
|
||||
- (미수행 — 배포 후 확인 필요)
|
||||
- Request Log Fields:
|
||||
- (미수행 — 배포 후 확인 필요)
|
||||
- Smoke or Manual Verification:
|
||||
- (미수행 — Verification Checklist 참조)
|
||||
|
||||
### Rollback Outcome
|
||||
|
||||
- Rollback Required:
|
||||
- (미기록)
|
||||
- Rollback Trigger:
|
||||
- none
|
||||
- Rollback Action:
|
||||
- none
|
||||
|
||||
### Release Decision Evidence
|
||||
|
||||
- Released Decision:
|
||||
- (미기록)
|
||||
- Rationale:
|
||||
- (미기록)
|
||||
- Recorded By:
|
||||
- (미기록)
|
||||
- Recorded At:
|
||||
- (미기록)
|
||||
|
||||
## Feedback Handoff
|
||||
|
||||
- Observation Summary:
|
||||
- (배포 후 기록)
|
||||
- Discovery Input:
|
||||
- (배포 후 기록)
|
||||
- REQ Input:
|
||||
- (배포 후 기록)
|
||||
- Change Request Input:
|
||||
- (배포 후 기록)
|
||||
+49
-9
@@ -4,8 +4,8 @@
|
||||
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml
|
||||
- Status: confirmed
|
||||
- Owner: repository-maintainers
|
||||
- Last Updated (KST): 2026-04-30 22:13
|
||||
- Source Discovery / Batch: bootstrap-baseline
|
||||
- Last Updated (KST): 2026-05-01 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001
|
||||
- Primary Runtime: mixed
|
||||
|
||||
## Project Summary
|
||||
@@ -57,18 +57,58 @@
|
||||
- Request validation and auth resolution should happen before persistence mutation or response rendering.
|
||||
- The health endpoint remains unauthenticated so infrastructure can probe readiness without a token.
|
||||
|
||||
## Flow: `v1 API — Service-Token Authentication`
|
||||
|
||||
1. v1 endpoint receives Bearer token from Authorization header.
|
||||
2. `require_read` resolves the token: checks env tokens first, then DB lookup via `get_token_principal()`.
|
||||
3. If DB token: validates `token_source == "db"` and `token_type == "service"`, else returns HTTP 403.
|
||||
4. `AuthContext` is populated with `user_id`, `token_type`, `permission`, and `runtime_id`.
|
||||
|
||||
## Flow: `v1 API — GET /v1/assets`
|
||||
|
||||
1. Service-token auth required (see above).
|
||||
2. `list_portfolios_with_strategies()` fetches all portfolios from DB.
|
||||
3. Optional `?status=<value>` query param filters portfolios by status field.
|
||||
4. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`.
|
||||
5. Returns `AssetsResponse` with `total` and `assets[]` using new field names: `cash`, `quantity`, `broker_type`, `strategy_params`.
|
||||
|
||||
## Flow: `v1 API — GET /v1/user_info`
|
||||
|
||||
1. Service-token auth required. HTTP 404 if `runtime_id` is None.
|
||||
2. `get_user()` fetches user record by `user_id`.
|
||||
3. `list_broker_credentials()` fetches all broker credential rows for the user.
|
||||
4. For each credential, Fernet-decrypts `encrypted_payload` using `CREDENTIAL_ENCRYPTION_KEY`; decryption failures log a warning and return `credential_payload: null`.
|
||||
5. Returns `UserInfoResponse` with `trading_enabled` (from `is_active`), `disabled_reason`, and `brokers[]`.
|
||||
|
||||
## Flow: `v1 API — POST /v1/assets/{asset_id}/ledger/trades`
|
||||
|
||||
1. Service-token auth required with `trade-write` permission.
|
||||
2. Validates asset existence via `repo.get(asset_id)`; HTTP 404 if missing.
|
||||
3. Calls `insert_ledger_trade()` with idempotency key `(asset_id, order_id, execution_id)`.
|
||||
4. If new record: returns HTTP 201 with `status="new"`.
|
||||
5. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`.
|
||||
6. If duplicate key with different payload: returns HTTP 409 with conflict detail.
|
||||
|
||||
## Flow: `v1 API — POST /auth/service-tokens`
|
||||
|
||||
1. Admin token auth required (`require_admin` dependency).
|
||||
2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`.
|
||||
3. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id`.
|
||||
4. Returns HTTP 201 with `token` (raw value), `runtime_id`, and `auth_token` metadata.
|
||||
|
||||
## Flow: `Application Startup Validation`
|
||||
|
||||
1. `lifespan` startup hook runs `_bootstrap_initial_user()` if DB enabled and bootstrap credentials configured.
|
||||
2. If `db_enabled=True`, calls `settings.require_credential_encryption_key()`.
|
||||
3. If `CREDENTIAL_ENCRYPTION_KEY` is not configured, raises `RuntimeError` and aborts startup.
|
||||
|
||||
## Current Gaps / Planned Changes
|
||||
|
||||
- End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending.
|
||||
- Redis and Loki are configured as supporting infrastructure but are not yet represented as explicit runtime flows.
|
||||
- Ownership-scoped authorization remains an open evolution area after bootstrap.
|
||||
|
||||
## Update Triggers
|
||||
|
||||
- Update this document when a command entry point changes.
|
||||
- Update this document when a runtime orchestration path changes.
|
||||
- Update this document when a batch changes the approved flow baseline.
|
||||
- Broker credential write path (upsert) is available only via CLI; no HTTP API endpoint for credential registration exists yet.
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-01 KST: bat-001 — Added v1 API flows (service-token auth, GET /v1/assets, GET /v1/user_info, POST ledger/trades, POST /auth/service-tokens), startup validation flow.
|
||||
- 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
|
||||
@@ -68,6 +68,9 @@ class _BootstrapRepo:
|
||||
self.closed = True
|
||||
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
def test_startup_bootstraps_initial_user_when_users_table_empty() -> None:
|
||||
repo = _BootstrapRepo()
|
||||
settings = Settings(
|
||||
@@ -76,6 +79,7 @@ def test_startup_bootstraps_initial_user_when_users_table_empty() -> None:
|
||||
bootstrap_email="admin@example.com",
|
||||
bootstrap_password="bootstrap123",
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
)
|
||||
bootstrap_app = create_app(settings_factory=lambda: settings, repository_factory=lambda current: repo)
|
||||
|
||||
@@ -99,6 +103,7 @@ def test_startup_does_not_bootstrap_when_users_exist() -> None:
|
||||
bootstrap_email="admin@example.com",
|
||||
bootstrap_password="bootstrap123",
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
)
|
||||
bootstrap_app = create_app(settings_factory=lambda: settings, repository_factory=lambda current: repo)
|
||||
|
||||
|
||||
+551
-55
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user