feat: 주요 CLI, 인증, 서비스 로직 및 문서 개선
This commit is contained in:
@@ -2,6 +2,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import mask_secret
|
||||
from app.domain.models import User
|
||||
from app.main import app, create_app
|
||||
|
||||
@@ -104,3 +105,17 @@ def test_startup_does_not_bootstrap_when_users_exist() -> None:
|
||||
|
||||
assert repo.created == []
|
||||
assert repo.closed is True
|
||||
|
||||
|
||||
def test_mask_secret_keeps_prefix_and_suffix() -> None:
|
||||
masked = mask_secret("abcdefghijklmnopqrstuvwxyz", prefix=6, suffix=4)
|
||||
|
||||
assert masked.startswith("abcdef")
|
||||
assert masked.endswith("wxyz")
|
||||
assert "ghijklmnopqrstuv" not in masked
|
||||
|
||||
|
||||
def test_mask_secret_short_value_is_fully_masked() -> None:
|
||||
masked = mask_secret("abcd", prefix=3, suffix=2)
|
||||
|
||||
assert masked == "****"
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.auth import AuthContext, Permission
|
||||
from app.core.config import Settings
|
||||
from app.core.config import get_settings
|
||||
from app.domain.models import AuthToken, BotPrincipal
|
||||
from app.main import app
|
||||
from app.schemas.auth import CreateBotTokenRequest
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
|
||||
def _client_with_tokens() -> TestClient:
|
||||
@@ -63,3 +71,137 @@ def test_admin_route_accepts_admin_token(monkeypatch) -> None:
|
||||
|
||||
assert response.status_code == 501
|
||||
assert response.json()["detail"] == "Portfolio persistence is not implemented yet"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AuthRepo:
|
||||
fail_audit: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.bot = BotPrincipal(
|
||||
id="bot-1",
|
||||
name="bot",
|
||||
owner_user_id="owner-1",
|
||||
permission="trade-write",
|
||||
is_active=True,
|
||||
)
|
||||
self.tokens: dict[str, AuthToken] = {}
|
||||
self._seq = 1
|
||||
|
||||
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
|
||||
if bot_principal_id != self.bot.id:
|
||||
raise ValueError("봇을 찾을 수 없습니다")
|
||||
return self.bot
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
permission: str,
|
||||
token_type: str,
|
||||
bot_principal_id: str | None = None,
|
||||
expires_at: datetime | 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,
|
||||
bot_principal_id=bot_principal_id,
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._seq += 1
|
||||
self.tokens[token.id] = token
|
||||
return token
|
||||
|
||||
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
|
||||
token = self.tokens.get(token_id)
|
||||
if token is None or token.bot_principal_id != bot_principal_id:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
def record_audit_event(self, payload: dict[str, object]) -> None:
|
||||
if self.fail_audit:
|
||||
raise RuntimeError("audit failed")
|
||||
|
||||
|
||||
def _service(repo: _AuthRepo) -> AuthService:
|
||||
return AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"))
|
||||
|
||||
|
||||
def test_issue_bot_token_defaults_to_365_days() -> None:
|
||||
repo = _AuthRepo()
|
||||
service = _service(repo)
|
||||
|
||||
result = service.issue_bot_token("bot-1", CreateBotTokenRequest(name="cli"))
|
||||
|
||||
assert result.auth_token.expires_at is not None
|
||||
assert (result.auth_token.expires_at - datetime.now(timezone.utc)).days >= 364
|
||||
|
||||
|
||||
def test_issue_owned_bot_token_rejects_no_expiry_for_non_admin() -> None:
|
||||
repo = _AuthRepo()
|
||||
service = _service(repo)
|
||||
|
||||
auth_context = AuthContext(
|
||||
token="db-token",
|
||||
permission=Permission.TRADE_WRITE,
|
||||
user_id="owner-1",
|
||||
token_source="db",
|
||||
)
|
||||
|
||||
try:
|
||||
service.issue_owned_bot_token(
|
||||
auth_context,
|
||||
"bot-1",
|
||||
CreateBotTokenRequest(name="cli", no_expiry=True),
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "비관리자" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected ValueError for non-admin no-expiry")
|
||||
|
||||
|
||||
def test_issue_bot_token_rolls_back_when_audit_fails() -> None:
|
||||
repo = _AuthRepo(fail_audit=True)
|
||||
service = _service(repo)
|
||||
|
||||
try:
|
||||
service.issue_bot_token("bot-1", CreateBotTokenRequest(name="cli"))
|
||||
except ValueError as exc:
|
||||
assert "발급을 취소" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected ValueError on audit failure")
|
||||
|
||||
assert len(repo.tokens) == 1
|
||||
token = next(iter(repo.tokens.values()))
|
||||
assert token.revoked_at is not None
|
||||
|
||||
|
||||
def test_revoke_bot_token_keeps_revoke_when_audit_fails() -> None:
|
||||
repo = _AuthRepo(fail_audit=True)
|
||||
service = _service(repo)
|
||||
created = repo.create_auth_token(
|
||||
"bot-1",
|
||||
name="cli",
|
||||
token_hash="hash",
|
||||
token_prefix="prefix",
|
||||
permission="trade-write",
|
||||
token_type="bot",
|
||||
bot_principal_id="bot-1",
|
||||
)
|
||||
|
||||
try:
|
||||
service.revoke_bot_token("bot-1", created.id)
|
||||
except ValueError as exc:
|
||||
assert "토큰은 폐기" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected ValueError on audit failure")
|
||||
|
||||
assert repo.tokens[created.id].revoked_at is not None
|
||||
|
||||
+152
-1
@@ -9,7 +9,8 @@ from typer.testing import CliRunner
|
||||
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
from app.core.security import hash_token, token_prefix
|
||||
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -22,6 +23,17 @@ class FakeRepo:
|
||||
self._user_seq = 1
|
||||
self._comments: dict[str, PortfolioComment] = {}
|
||||
self._tags: dict[str, PortfolioTag] = {}
|
||||
self._bot_principals: dict[str, BotPrincipal] = {
|
||||
"bot-1": BotPrincipal(
|
||||
id="bot-1",
|
||||
name="cli-bot",
|
||||
owner_user_id="owner-1",
|
||||
permission="trade-write",
|
||||
is_active=True,
|
||||
)
|
||||
}
|
||||
self._auth_tokens: dict[str, AuthToken] = {}
|
||||
self._token_seq = 1
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -103,6 +115,49 @@ class FakeRepo:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
del self._users[user_id]
|
||||
|
||||
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
|
||||
bot = self._bot_principals.get(bot_principal_id)
|
||||
if bot is None:
|
||||
raise ValueError("봇을 찾을 수 없습니다")
|
||||
return bot
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
permission: str,
|
||||
token_type: str,
|
||||
bot_principal_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
id=f"token-{self._token_seq}",
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
token_prefix=token_prefix,
|
||||
permission=permission,
|
||||
token_type=token_type,
|
||||
bot_principal_id=bot_principal_id,
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._token_seq += 1
|
||||
self._auth_tokens[token.id] = token
|
||||
return token
|
||||
|
||||
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
|
||||
token = self._auth_tokens.get(token_id)
|
||||
if token is None or token.bot_principal_id != bot_principal_id:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
def record_audit_event(self, payload: dict[str, object]) -> None:
|
||||
if payload.get("force_fail"):
|
||||
raise RuntimeError("audit failure")
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
@@ -769,3 +824,99 @@ def test_cli_tag_remove_flow() -> None:
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Removed tag: grade:A" in result.stdout
|
||||
|
||||
|
||||
def test_cli_help_exposes_bot_token_commands() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["bot", "token", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "issue" in result.stdout
|
||||
assert "revoke" in result.stdout
|
||||
|
||||
|
||||
def test_cli_bot_token_issue_supports_json() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "bot", "token", "issue", "bot-1", "--name", "nightly"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["bot_id"] == "bot-1"
|
||||
assert payload["revoked"] is False
|
||||
assert payload["expires_at"] is not None
|
||||
assert payload["token"].endswith("...") is False
|
||||
|
||||
|
||||
def test_cli_bot_token_issue_rejects_conflicting_expiry_options() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
[
|
||||
"bot",
|
||||
"token",
|
||||
"issue",
|
||||
"bot-1",
|
||||
"--ttl-days",
|
||||
"7",
|
||||
"--no-expiry",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "동시에 사용할 수 없습니다" in result.stdout
|
||||
|
||||
|
||||
def test_cli_bot_token_revoke_can_abort() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
token = repo.create_auth_token(
|
||||
"bot-1",
|
||||
name="to-revoke",
|
||||
token_hash=hash_token("token-value"),
|
||||
token_prefix=token_prefix("token-value"),
|
||||
permission="trade-write",
|
||||
token_type="bot",
|
||||
bot_principal_id="bot-1",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["bot", "token", "revoke", "bot-1", token.id],
|
||||
input="n\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert repo._auth_tokens[token.id].revoked_at is None
|
||||
|
||||
|
||||
def test_cli_bot_token_revoke_force_succeeds() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
token = repo.create_auth_token(
|
||||
"bot-1",
|
||||
name="to-revoke",
|
||||
token_hash=hash_token("token-value"),
|
||||
token_prefix=token_prefix("token-value"),
|
||||
permission="trade-write",
|
||||
token_type="bot",
|
||||
bot_principal_id="bot-1",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "bot", "token", "revoke", "bot-1", token.id, "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["token_id"] == token.id
|
||||
assert payload["revoked"] is True
|
||||
|
||||
Reference in New Issue
Block a user