작업 내용 저장
This commit is contained in:
+55
-72
@@ -8,9 +8,8 @@ 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.domain.models import AuthToken
|
||||
from app.main import app
|
||||
from app.schemas.auth import CreateBotTokenRequest
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
|
||||
@@ -75,23 +74,19 @@ def test_admin_route_accepts_admin_token(monkeypatch) -> None:
|
||||
|
||||
@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.users: dict[str, object] = {}
|
||||
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 get_user_by_username(self, username: str):
|
||||
return self.users.get(username)
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||
for token in self.tokens.values():
|
||||
if token.name == name and token.token_type == "service":
|
||||
return token
|
||||
return None
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
@@ -102,7 +97,6 @@ class _AuthRepo:
|
||||
token_prefix: str,
|
||||
permission: str,
|
||||
token_type: str,
|
||||
bot_principal_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
@@ -112,7 +106,6 @@ class _AuthRepo:
|
||||
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),
|
||||
)
|
||||
@@ -120,88 +113,78 @@ class _AuthRepo:
|
||||
self.tokens[token.id] = token
|
||||
return token
|
||||
|
||||
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
|
||||
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
|
||||
token = self.tokens.get(token_id)
|
||||
if token is None or token.bot_principal_id != bot_principal_id:
|
||||
if token is None or token.user_id != user_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:
|
||||
def test_issue_service_token_defaults_to_365_days() -> None:
|
||||
from dataclasses import dataclass as _dc
|
||||
from app.domain.models import User
|
||||
|
||||
repo = _AuthRepo()
|
||||
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
|
||||
service = _service(repo)
|
||||
|
||||
result = service.issue_bot_token("bot-1", CreateBotTokenRequest(name="cli"))
|
||||
result = service.issue_service_token("my-token", "alice")
|
||||
|
||||
assert result.auth_token.expires_at is not 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"
|
||||
|
||||
|
||||
def test_issue_owned_bot_token_rejects_no_expiry_for_non_admin() -> None:
|
||||
def test_issue_service_token_no_expiry() -> None:
|
||||
from app.domain.models import User
|
||||
|
||||
repo = _AuthRepo()
|
||||
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
|
||||
service = _service(repo)
|
||||
|
||||
result = service.issue_service_token("my-token", "alice", no_expiry=True)
|
||||
|
||||
assert result.auth_token.expires_at is None
|
||||
|
||||
|
||||
def test_issue_service_token_unknown_user_raises() -> 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),
|
||||
)
|
||||
service.issue_service_token("my-token", "unknown")
|
||||
except ValueError as exc:
|
||||
assert "비관리자" in str(exc)
|
||||
assert "unknown" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected ValueError for non-admin no-expiry")
|
||||
raise AssertionError("Expected ValueError for unknown user")
|
||||
|
||||
|
||||
def test_issue_bot_token_rolls_back_when_audit_fails() -> None:
|
||||
repo = _AuthRepo(fail_audit=True)
|
||||
def test_revoke_service_token_success() -> None:
|
||||
from app.domain.models import User
|
||||
|
||||
repo = _AuthRepo()
|
||||
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
|
||||
service = _service(repo)
|
||||
issued = service.issue_service_token("my-token", "alice")
|
||||
|
||||
revoked_at = service.revoke_service_token("my-token")
|
||||
|
||||
assert revoked_at is not None
|
||||
assert repo.tokens[issued.auth_token.id].revoked_at is not None
|
||||
|
||||
|
||||
def test_revoke_service_token_not_found_raises() -> None:
|
||||
repo = _AuthRepo()
|
||||
service = _service(repo)
|
||||
|
||||
try:
|
||||
service.issue_bot_token("bot-1", CreateBotTokenRequest(name="cli"))
|
||||
service.revoke_service_token("nonexistent")
|
||||
except ValueError as exc:
|
||||
assert "발급을 취소" in str(exc)
|
||||
assert "nonexistent" 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
|
||||
raise AssertionError("Expected ValueError for missing service token")
|
||||
|
||||
+29
-42
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.core.security import hash_token, token_prefix
|
||||
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -23,15 +23,6 @@ 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
|
||||
@@ -115,11 +106,11 @@ 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 get_user_by_username(self, username: str):
|
||||
return next((u for u in self._users.values() if u.username == username), None)
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||
return next((t for t in self._auth_tokens.values() if t.name == name and t.token_type == "service"), None)
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
@@ -130,7 +121,6 @@ class FakeRepo:
|
||||
token_prefix: str,
|
||||
permission: str,
|
||||
token_type: str,
|
||||
bot_principal_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
@@ -140,7 +130,6 @@ class FakeRepo:
|
||||
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),
|
||||
)
|
||||
@@ -148,16 +137,12 @@ class FakeRepo:
|
||||
self._auth_tokens[token.id] = token
|
||||
return token
|
||||
|
||||
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
|
||||
def revoke_auth_token(self, user_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:
|
||||
if token is None or token.user_id != user_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("포트폴리오를 찾을 수 없습니다")
|
||||
@@ -837,25 +822,26 @@ def test_cli_help_exposes_bot_token_commands() -> None:
|
||||
assert "revoke" in result.stdout
|
||||
|
||||
|
||||
def test_cli_bot_token_issue_supports_json() -> None:
|
||||
def test_cli_service_token_issue_supports_json() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "service-token", "issue", "bot-1", "--name", "nightly"],
|
||||
["--format", "json", "service-token", "issue", "nightly", "--user", "alice"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["bot_id"] == "bot-1"
|
||||
assert payload["name"] == "nightly"
|
||||
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:
|
||||
def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo.create_user("alice", permission="read")
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
@@ -863,7 +849,8 @@ def test_cli_bot_token_issue_rejects_conflicting_expiry_options() -> None:
|
||||
[
|
||||
"service-token",
|
||||
"issue",
|
||||
"bot-1",
|
||||
"nightly",
|
||||
"--user", "alice",
|
||||
"--ttl-days",
|
||||
"7",
|
||||
"--no-expiry",
|
||||
@@ -874,22 +861,22 @@ def test_cli_bot_token_issue_rejects_conflicting_expiry_options() -> None:
|
||||
assert "동시에 사용할 수 없습니다" in result.stdout
|
||||
|
||||
|
||||
def test_cli_bot_token_revoke_can_abort() -> None:
|
||||
def test_cli_service_token_revoke_can_abort() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
token = repo.create_auth_token(
|
||||
"bot-1",
|
||||
user.id,
|
||||
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",
|
||||
permission="read",
|
||||
token_type="service",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["service-token", "revoke", "bot-1", token.id],
|
||||
["service-token", "revoke", "to-revoke"],
|
||||
input="n\n",
|
||||
)
|
||||
|
||||
@@ -897,25 +884,25 @@ def test_cli_bot_token_revoke_can_abort() -> None:
|
||||
assert repo._auth_tokens[token.id].revoked_at is None
|
||||
|
||||
|
||||
def test_cli_bot_token_revoke_force_succeeds() -> None:
|
||||
def test_cli_service_token_revoke_force_succeeds() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
token = repo.create_auth_token(
|
||||
"bot-1",
|
||||
user.id,
|
||||
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",
|
||||
permission="read",
|
||||
token_type="service",
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "service-token", "revoke", "bot-1", token.id, "--force"],
|
||||
["--format", "json", "service-token", "revoke", "to-revoke", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["token_id"] == token.id
|
||||
assert payload["name"] == "to-revoke"
|
||||
assert payload["revoked"] is True
|
||||
|
||||
@@ -137,8 +137,7 @@ def test_owner_bound_bot_token_can_read_owned_portfolio(monkeypatch) -> None:
|
||||
token_id="t-bot-1",
|
||||
user_id="owner-1",
|
||||
permission="read",
|
||||
token_type="bot",
|
||||
bot_principal_id="bot-1",
|
||||
token_type="service",
|
||||
)
|
||||
repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={hash_token(token): principal})
|
||||
client = _configure_client(monkeypatch, repo)
|
||||
|
||||
+4
-217
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
import app.api.users as users_api
|
||||
from app.core.security import hash_password, hash_token
|
||||
from app.domain.models import AuthToken, BotPrincipal, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, TokenPrincipal, User
|
||||
from app.main import app
|
||||
from app.core.auth import get_auth_repository
|
||||
from app.services.auth_service import AuthService, get_auth_service
|
||||
@@ -19,10 +19,8 @@ class FakeUserRepo:
|
||||
self.users_by_username: dict[str, User] = {}
|
||||
self.tokens: dict[str, AuthToken] = {}
|
||||
self.token_principals: dict[str, TokenPrincipal] = {}
|
||||
self.bots: dict[str, BotPrincipal] = {}
|
||||
self.next_id = 1
|
||||
self.next_token_id = 1
|
||||
self.next_bot_id = 1
|
||||
|
||||
def get_user(self, user_id: str) -> User:
|
||||
user = self.users.get(user_id)
|
||||
@@ -114,7 +112,6 @@ class FakeUserRepo:
|
||||
token_prefix: str,
|
||||
permission: str,
|
||||
token_type: str,
|
||||
bot_principal_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> AuthToken:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -125,7 +122,6 @@ class FakeUserRepo:
|
||||
token_prefix=token_prefix,
|
||||
permission=permission,
|
||||
token_type=token_type,
|
||||
bot_principal_id=bot_principal_id,
|
||||
expires_at=expires_at,
|
||||
created_at=now,
|
||||
)
|
||||
@@ -136,7 +132,6 @@ class FakeUserRepo:
|
||||
user_id=user_id,
|
||||
permission=permission,
|
||||
token_type=token_type,
|
||||
bot_principal_id=bot_principal_id,
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -149,6 +144,9 @@ class FakeUserRepo:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||
return next((t for t in self.tokens.values() if t.name == name and t.token_type == "service"), None)
|
||||
|
||||
def get_token_principal(self, token_hash_value: str) -> TokenPrincipal | None:
|
||||
principal = self.token_principals.get(token_hash_value)
|
||||
if principal is None:
|
||||
@@ -156,91 +154,11 @@ class FakeUserRepo:
|
||||
token = self.tokens.get(principal.token_id)
|
||||
if token is None or token.revoked_at is not None:
|
||||
return None
|
||||
if token.bot_principal_id is not None:
|
||||
bot = self.bots.get(token.bot_principal_id)
|
||||
if bot is None or not bot.is_active:
|
||||
return None
|
||||
if token.expires_at is not None and token.expires_at <= datetime.now(timezone.utc):
|
||||
return None
|
||||
token.last_used_at = datetime.now(timezone.utc)
|
||||
if token.bot_principal_id is not None:
|
||||
bot = self.get_bot_principal(token.bot_principal_id)
|
||||
return TokenPrincipal(
|
||||
token_id=principal.token_id,
|
||||
user_id=bot.owner_user_id,
|
||||
permission=principal.permission,
|
||||
token_type=principal.token_type,
|
||||
bot_principal_id=principal.bot_principal_id,
|
||||
)
|
||||
return principal
|
||||
|
||||
def create_bot_principal(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
owner_user_id: str,
|
||||
permission: str,
|
||||
description: str | None = None,
|
||||
) -> BotPrincipal:
|
||||
if any(item.name == name for item in self.bots.values()):
|
||||
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
|
||||
self.get_user(owner_user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
bot = BotPrincipal(
|
||||
id=f"bot-{self.next_bot_id}",
|
||||
name=name,
|
||||
owner_user_id=owner_user_id,
|
||||
permission=permission,
|
||||
description=description,
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.next_bot_id += 1
|
||||
self.bots[bot.id] = bot
|
||||
return bot
|
||||
|
||||
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
|
||||
bot = self.bots.get(bot_principal_id)
|
||||
if bot is None:
|
||||
raise ValueError("봇을 찾을 수 없습니다")
|
||||
return bot
|
||||
|
||||
def list_bot_principals(
|
||||
self,
|
||||
*,
|
||||
is_active: bool | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
) -> list[BotPrincipal]:
|
||||
items = list(self.bots.values())
|
||||
if is_active is not None:
|
||||
items = [item for item in items if item.is_active == is_active]
|
||||
if owner_user_id is not None:
|
||||
items = [item for item in items if item.owner_user_id == owner_user_id]
|
||||
return sorted(items, key=lambda item: item.name)
|
||||
|
||||
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
|
||||
bot = self.get_bot_principal(bot_principal_id)
|
||||
bot.is_active = False
|
||||
now = datetime.now(timezone.utc)
|
||||
bot.deactivated_at = now
|
||||
bot.updated_at = now
|
||||
for token in self.tokens.values():
|
||||
if token.bot_principal_id == bot_principal_id and token.revoked_at is None:
|
||||
token.revoked_at = now
|
||||
return bot
|
||||
|
||||
def list_bot_auth_tokens(self, bot_principal_id: str) -> list[AuthToken]:
|
||||
self.get_bot_principal(bot_principal_id)
|
||||
return [token for token in self.tokens.values() if token.bot_principal_id == bot_principal_id]
|
||||
|
||||
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
|
||||
self.get_bot_principal(bot_principal_id)
|
||||
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 _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
@@ -347,134 +265,3 @@ def test_login_and_issue_client_token_flow(monkeypatch) -> None:
|
||||
assert token_payload["token"]
|
||||
|
||||
|
||||
def test_admin_can_create_bot_and_issue_token(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
admin = repo.create_user("admin", password_hash=hash_password("admin123!"), permission="admin")
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
login_response = client.post("/auth/login", json={"username": "admin", "password": "admin123!"})
|
||||
assert login_response.status_code == 200
|
||||
admin_token = login_response.json()["access_token"]
|
||||
|
||||
bot_response = client.post(
|
||||
"/auth/bots",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"name": "market-bot",
|
||||
"owner_user_id": admin.id,
|
||||
"permission": "trade-write",
|
||||
"description": "external execution bot",
|
||||
},
|
||||
)
|
||||
assert bot_response.status_code == 201
|
||||
bot_id = bot_response.json()["id"]
|
||||
|
||||
issue_response = client.post(
|
||||
f"/auth/bots/{bot_id}/tokens",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"name": "prod-key", "permission": "read", "expires_in_days": 30},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert issue_response.status_code == 201
|
||||
payload = issue_response.json()
|
||||
assert payload["token"]
|
||||
assert payload["auth_token"]["token_type"] == "bot"
|
||||
assert payload["auth_token"]["bot_principal_id"] == bot_id
|
||||
assert payload["auth_token"]["permission"] == "read"
|
||||
assert payload["auth_token"]["expires_at"] is not None
|
||||
|
||||
|
||||
def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
admin = repo.create_user("admin", password_hash=hash_password("admin123!"), permission="admin")
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
login_response = client.post("/auth/login", json={"username": "admin", "password": "admin123!"})
|
||||
assert login_response.status_code == 200
|
||||
admin_token = login_response.json()["access_token"]
|
||||
|
||||
bot_response = client.post(
|
||||
"/auth/bots",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"name": "ops-bot", "owner_user_id": admin.id, "permission": "read"},
|
||||
)
|
||||
assert bot_response.status_code == 201
|
||||
bot_id = bot_response.json()["id"]
|
||||
|
||||
issue_response = client.post(
|
||||
f"/auth/bots/{bot_id}/tokens",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"name": "ops-key", "permission": "read"},
|
||||
)
|
||||
assert issue_response.status_code == 201
|
||||
bot_token = issue_response.json()["token"]
|
||||
|
||||
deactivate_response = client.delete(
|
||||
f"/auth/bots/{bot_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert deactivate_response.status_code == 200
|
||||
|
||||
denied_response = client.get("/auth/tokens", headers={"Authorization": f"Bearer {bot_token}"})
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert denied_response.status_code == 401
|
||||
assert denied_response.json()["detail"] == "Invalid token"
|
||||
|
||||
|
||||
def test_user_can_create_and_issue_owned_bot_token(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
user = repo.create_user("alice", password_hash=hash_password("alice123!"), permission="trade-write")
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
login_response = client.post("/auth/login", json={"username": "alice", "password": "alice123!"})
|
||||
assert login_response.status_code == 200
|
||||
user_token = login_response.json()["access_token"]
|
||||
|
||||
bot_response = client.post(
|
||||
"/auth/me/bots",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"name": "alice-bot", "permission": "read", "description": "personal bot"},
|
||||
)
|
||||
assert bot_response.status_code == 201
|
||||
bot_id = bot_response.json()["id"]
|
||||
assert bot_response.json()["owner_user_id"] == user.id
|
||||
|
||||
list_response = client.get("/auth/me/bots", headers={"Authorization": f"Bearer {user_token}"})
|
||||
assert list_response.status_code == 200
|
||||
assert [item["id"] for item in list_response.json()] == [bot_id]
|
||||
|
||||
issue_response = client.post(
|
||||
f"/auth/me/bots/{bot_id}/tokens",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={"name": "alice-key", "permission": "read"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert issue_response.status_code == 201
|
||||
payload = issue_response.json()
|
||||
assert payload["auth_token"]["token_type"] == "bot"
|
||||
assert payload["auth_token"]["bot_principal_id"] == bot_id
|
||||
|
||||
|
||||
def test_user_cannot_issue_token_for_other_users_bot(monkeypatch) -> None:
|
||||
repo = FakeUserRepo()
|
||||
owner = repo.create_user("owner", password_hash=hash_password("owner123!"), permission="trade-write")
|
||||
other = repo.create_user("other", password_hash=hash_password("other123!"), permission="trade-write")
|
||||
bot = repo.create_bot_principal(name="owner-bot", owner_user_id=owner.id, permission="read")
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
login_response = client.post("/auth/login", json={"username": "other", "password": "other123!"})
|
||||
assert login_response.status_code == 200
|
||||
other_token = login_response.json()["access_token"]
|
||||
|
||||
issue_response = client.post(
|
||||
f"/auth/me/bots/{bot.id}/tokens",
|
||||
headers={"Authorization": f"Bearer {other_token}"},
|
||||
json={"name": "invalid-key", "permission": "read"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert issue_response.status_code == 403
|
||||
assert issue_response.json()["detail"] == "봇 접근 권한이 없습니다"
|
||||
Reference in New Issue
Block a user