작업 내용 저장
This commit is contained in:
+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