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:
@@ -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)
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
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(
|
||||
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 resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["runtime_id"] == "rt-new-123"
|
||||
assert "token" in body
|
||||
assert issue_resp.status_code == 201
|
||||
issue_body = issue_resp.json()
|
||||
assert issue_body["runtime_id"] is not None
|
||||
assert "token" in issue_body
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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]:
|
||||
|
||||
Reference in New Issue
Block a user