208 lines
6.4 KiB
Python
208 lines
6.4 KiB
Python
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:
|
|
get_settings.cache_clear()
|
|
app.dependency_overrides.clear()
|
|
return TestClient(app)
|
|
|
|
|
|
def test_read_route_requires_token(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
|
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
|
|
get_settings.cache_clear()
|
|
client = _client_with_tokens()
|
|
|
|
response = client.get("/portfolios")
|
|
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == "Missing bearer token"
|
|
|
|
|
|
def test_read_route_accepts_read_token(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
|
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
|
|
get_settings.cache_clear()
|
|
client = _client_with_tokens()
|
|
|
|
response = client.get("/portfolios", headers={"Authorization": "Bearer read-token"})
|
|
|
|
assert response.status_code == 501
|
|
assert response.json()["detail"] == "Portfolio persistence is not implemented yet"
|
|
|
|
|
|
def test_trade_route_rejects_read_token(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
|
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
|
|
monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "trade-token")
|
|
get_settings.cache_clear()
|
|
client = _client_with_tokens()
|
|
|
|
response = client.post(
|
|
"/portfolios/p1/buy",
|
|
headers={"Authorization": "Bearer read-token"},
|
|
json={"quantity": 1, "price": 1000, "fee": 0, "tax": 0, "note": ""},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == "Insufficient permission"
|
|
|
|
|
|
def test_admin_route_accepts_admin_token(monkeypatch) -> None:
|
|
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
|
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
|
|
get_settings.cache_clear()
|
|
client = _client_with_tokens()
|
|
|
|
response = client.delete("/portfolios/p1", headers={"Authorization": "Bearer admin-token"})
|
|
|
|
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
|