feat: implement v1 consumer API endpoints with runtime_id support (REQ-004~010)

- Generate runtime_id for service-token issuance (CLI and API)
- Implement GET /v1/assets endpoint with asset view and strategy_params
- Implement GET /v1/user_info endpoint with trading_enabled and broker credentials
- Implement POST /v1/assets/{asset_id}/ledger/trades with idempotency support
- Update auth service to support runtime_id tracking and credential encryption
- Add comprehensive test coverage for v1 endpoints (dcy-002 implementation)
- Update postgres repository for ledger and broker credential queries
- Ensure all tests pass for feature validation
This commit is contained in:
2026-05-13 07:13:03 +00:00
parent 3255d096f1
commit f0a6caa178
7 changed files with 172 additions and 40 deletions
+16
View File
@@ -140,6 +140,22 @@ def test_issue_service_token_defaults_to_365_days() -> None:
assert (result.auth_token.expires_at - datetime.now(timezone.utc)).days >= 364
assert result.auth_token.token_type == "service"
assert result.auth_token.name == "my-token"
assert result.auth_token.runtime_id is not None
def test_issue_service_token_generates_distinct_runtime_ids() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
service = _service(repo)
first = service.issue_service_token("my-token-1", "alice")
second = service.issue_service_token("my-token-2", "alice")
assert first.auth_token.runtime_id is not None
assert second.auth_token.runtime_id is not None
assert first.auth_token.runtime_id != second.auth_token.runtime_id
def test_issue_service_token_no_expiry() -> None:
+3
View File
@@ -122,6 +122,7 @@ class FakeRepo:
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken:
token = AuthToken(
id=f"token-{self._token_seq}",
@@ -132,6 +133,7 @@ class FakeRepo:
token_type=token_type,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
runtime_id=runtime_id,
)
self._token_seq += 1
self._auth_tokens[token.id] = token
@@ -851,6 +853,7 @@ def test_cli_service_token_issue_supports_json() -> None:
assert payload["name"] == "nightly"
assert payload["revoked"] is False
assert payload["expires_at"] is not None
assert payload["runtime_id"] is not None
def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None:
+136 -34
View File
@@ -10,7 +10,7 @@ import pytest
from fastapi.testclient import TestClient
from app.core.auth import AuthContext, Permission
from app.core.config import Settings
from app.core.config import Settings, get_settings
from app.domain.models import AuthToken, BrokerCredential, Portfolio, TokenPrincipal, User, LedgerTrade
from app.main import create_app
@@ -31,8 +31,11 @@ def _make_settings(**kwargs) -> Settings:
def _make_app(settings: Settings | None = None) -> TestClient:
get_settings.cache_clear()
s = settings or _make_settings()
return TestClient(create_app(settings_factory=lambda: s))
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:
@@ -46,6 +49,76 @@ 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,
) -> 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,
)
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,
)
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
return []
# ---------------------------------------------------------------------------
# GET /health
# ---------------------------------------------------------------------------
@@ -68,43 +141,69 @@ def test_health_returns_full_response() -> None:
# ---------------------------------------------------------------------------
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,
repo = _RuntimeIdRoundTripRepo()
settings = _make_settings(
db_enabled=True,
admin_tokens_raw="admin-token",
credential_encryption_key=_TEST_ENC_KEY,
)
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
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"},
)
from app.api.auth_routes import get_auth_service
app_instance.dependency_overrides[get_auth_service] = lambda: mock_service
assert issue_resp.status_code == 201
issue_body = issue_resp.json()
assert issue_body["runtime_id"] is not None
assert "token" in issue_body
client = TestClient(app_instance)
resp = client.post(
"/auth/service-tokens",
json={"name": "bot-1", "username": "alice"},
user_info_resp = client.get(
"/v1/user_info",
headers=_service_token_header(issue_body["token"]),
)
assert user_info_resp.status_code == 200
user_info_body = user_info_resp.json()
assert user_info_body["runtime_id"] == issue_body["runtime_id"]
assert user_info_body["username"] == "alice"
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,
)
assert resp.status_code == 201
body = resp.json()
assert body["runtime_id"] == "rt-new-123"
assert "token" in body
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"]
# ---------------------------------------------------------------------------
@@ -612,8 +711,11 @@ def _make_settings(**kwargs) -> Settings:
def _make_app(settings: Settings | None = None) -> TestClient:
get_settings.cache_clear()
s = settings or _make_settings()
return TestClient(create_app(settings_factory=lambda: s))
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]: