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
+1
View File
@@ -78,6 +78,7 @@ def issue_service_token(
name=name,
expires_at=issued.auth_token.expires_at,
revoked=issued.auth_token.revoked_at is not None,
runtime_id=issued.auth_token.runtime_id,
token=issued.token,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
+1
View File
@@ -52,6 +52,7 @@ class PortfolioRepository(Protocol):
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken: ...
def list_auth_tokens(self, user_id: str) -> list[AuthToken]: ...
+12 -6
View File
@@ -38,7 +38,8 @@ _SELECT_AUTH_TOKEN = """
expires_at,
last_used_at,
revoked_at,
created_at
created_at,
runtime_id
FROM auth_tokens
"""
@@ -133,6 +134,7 @@ class PostgresPortfolioRepository:
token_hash TEXT NOT NULL UNIQUE,
permission TEXT NOT NULL,
token_type TEXT NOT NULL,
runtime_id TEXT,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
@@ -140,6 +142,7 @@ class PostgresPortfolioRepository:
)
"""
)
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS runtime_id TEXT")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
@@ -300,6 +303,7 @@ class PostgresPortfolioRepository:
last_used_at=row[7] if len(row) > 7 else None,
revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None,
runtime_id=row[10] if len(row) > 10 else None,
)
@staticmethod
@@ -532,6 +536,7 @@ class PostgresPortfolioRepository:
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
@@ -542,14 +547,14 @@ class PostgresPortfolioRepository:
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at
expires_at, last_used_at, revoked_at, created_at, runtime_id
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@@ -612,7 +617,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
FROM auth_tokens t
WHERE token_hash = %s
AND t.revoked_at IS NULL
@@ -629,6 +634,7 @@ class PostgresPortfolioRepository:
user_id=row[1],
permission=row[2],
token_type=row[3],
runtime_id=row[4] if len(row) > 4 else None,
)
def get(self, portfolio_id: str) -> Portfolio:
+3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@@ -131,6 +132,7 @@ class AuthService:
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
raw_token = generate_token()
runtime_id = str(uuid.uuid4())
try:
auth_token = self._repository.create_auth_token(
user.id,
@@ -140,6 +142,7 @@ class AuthService:
permission=permission,
token_type="service",
expires_at=expires_at,
runtime_id=runtime_id,
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
+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]: