Files
portfolio-hub/tests/test_users.py
T

480 lines
18 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
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.main import app
from app.core.auth import get_auth_repository
from app.services.auth_service import AuthService, get_auth_service
from app.services.user_service import UserService, get_user_service
class FakeUserRepo:
def __init__(self) -> None:
self.users: dict[str, User] = {}
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)
if user is None:
raise ValueError("사용자를 찾을 수 없습니다")
return user
def get_user_by_username(self, username: str) -> User | None:
return self.users_by_username.get(username)
def list_users(self) -> list[User]:
return sorted(self.users.values(), key=lambda user: user.username)
def create_user(
self,
username: str,
*,
email: str | None = None,
password_hash: str | None = None,
permission: str = "read",
is_active: bool = True,
) -> User:
if any(user.username == username for user in self.users.values()):
raise ValueError(f"이미 존재하는 username입니다: {username}")
if email is not None and any(user.email == email for user in self.users.values()):
raise ValueError(f"이미 존재하는 email입니다: {email}")
now = datetime.now(timezone.utc)
user = User(
id=f"user-{self.next_id}",
username=username,
email=email,
permission=permission,
is_active=is_active,
password_hash=password_hash,
created_at=now,
updated_at=now,
)
self.next_id += 1
self.users[user.id] = user
self.users_by_username[user.username] = user
return user
def update_user(
self,
user_id: str,
*,
username: str | None = None,
email: str | None = None,
password_hash: str | None = None,
permission: str | None = None,
is_active: bool | None = None,
) -> User:
user = self.get_user(user_id)
if username is not None and any(item.id != user_id and item.username == username for item in self.users.values()):
raise ValueError(f"이미 존재하는 username입니다: {username}")
if email is not None and any(item.id != user_id and item.email == email for item in self.users.values()):
raise ValueError(f"이미 존재하는 email입니다: {email}")
self.users_by_username.pop(user.username, None)
if username is not None:
user.username = username
if email is not None:
user.email = email
if password_hash is not None:
user.password_hash = password_hash
if permission is not None:
user.permission = permission
if is_active is not None:
user.is_active = is_active
user.updated_at = datetime.now(timezone.utc)
self.users_by_username[user.username] = user
return user
def delete_user(self, user_id: str) -> None:
if user_id not in self.users:
raise ValueError("사용자를 찾을 수 없습니다")
user = self.users.pop(user_id)
self.users_by_username.pop(user.username, None)
def update_user_last_login(self, user_id: str) -> None:
user = self.get_user(user_id)
user.last_login_at = datetime.now(timezone.utc)
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:
now = datetime.now(timezone.utc)
token = AuthToken(
id=f"token-{self.next_token_id}",
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=now,
)
self.next_token_id += 1
self.tokens[token.id] = token
self.token_principals[token_hash] = TokenPrincipal(
token_id=token.id,
user_id=user_id,
permission=permission,
token_type=token_type,
bot_principal_id=bot_principal_id,
)
return token
def list_auth_tokens(self, user_id: str) -> list[AuthToken]:
return [token for token in self.tokens.values() if token.user_id == user_id]
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
token = self.tokens.get(token_id)
if token is None or token.user_id != user_id:
raise ValueError("토큰을 찾을 수 없습니다")
token.revoked_at = datetime.now(timezone.utc)
def get_token_principal(self, token_hash_value: str) -> TokenPrincipal | None:
principal = self.token_principals.get(token_hash_value)
if principal is None:
return None
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")
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
from app.core.config import get_settings
get_settings.cache_clear()
app.dependency_overrides.clear()
app.dependency_overrides[get_user_service] = lambda: UserService(repo)
app.dependency_overrides[get_auth_repository] = lambda: repo
app.dependency_overrides[get_auth_service] = lambda: AuthService(repo, get_settings())
monkeypatch.setattr(users_api, "get_portfolio_repository_from_settings", lambda settings: repo)
return TestClient(app)
def test_list_users_requires_admin(monkeypatch) -> None:
client = _client_with_repo(monkeypatch, FakeUserRepo())
response = client.get("/users", headers={"Authorization": "Bearer read-token"})
app.dependency_overrides.clear()
assert response.status_code == 403
assert response.json()["detail"] == "Insufficient permission"
def test_list_users_returns_payload(monkeypatch) -> None:
repo = FakeUserRepo()
repo.create_user("alice", email="alice@example.com", password_hash=hash_password("password123"))
client = _client_with_repo(monkeypatch, repo)
response = client.get("/users", headers={"Authorization": "Bearer admin-token"})
app.dependency_overrides.clear()
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["username"] == "alice"
def test_create_user_rejects_duplicate_username(monkeypatch) -> None:
repo = FakeUserRepo()
repo.create_user("alice", password_hash=hash_password("password123"))
client = _client_with_repo(monkeypatch, repo)
response = client.post(
"/users",
headers={"Authorization": "Bearer admin-token"},
json={"username": "alice", "email": "alice2@example.com", "password": "password123"},
)
app.dependency_overrides.clear()
assert response.status_code == 409
assert "이미 존재하는 username입니다" in response.json()["detail"]
def test_get_update_and_delete_user_flow(monkeypatch) -> None:
repo = FakeUserRepo()
user = repo.create_user("alice", email="alice@example.com", password_hash=hash_password("password123"))
client = _client_with_repo(monkeypatch, repo)
get_response = client.get(f"/users/{user.id}", headers={"Authorization": "Bearer admin-token"})
update_response = client.patch(
f"/users/{user.id}",
headers={"Authorization": "Bearer admin-token"},
json={"username": "alice-updated"},
)
delete_response = client.delete(f"/users/{user.id}", headers={"Authorization": "Bearer admin-token"})
app.dependency_overrides.clear()
assert get_response.status_code == 200
assert get_response.json()["username"] == "alice"
assert update_response.status_code == 200
assert update_response.json()["username"] == "alice-updated"
assert delete_response.status_code == 204
def test_login_and_issue_client_token_flow(monkeypatch) -> None:
repo = FakeUserRepo()
repo.create_user(
"alice",
email="alice@example.com",
password_hash=hash_password("password123"),
permission="trade-write",
)
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "alice", "password": "password123"})
assert login_response.status_code == 200
login_payload = login_response.json()
session_token = login_payload["access_token"]
token_response = client.post(
"/auth/tokens",
headers={"Authorization": f"Bearer {session_token}"},
json={"name": "mobile-app", "permission": "read", "expires_in_days": 30},
)
app.dependency_overrides.clear()
assert token_response.status_code == 201
token_payload = token_response.json()
assert token_payload["auth_token"]["name"] == "mobile-app"
assert token_payload["auth_token"]["token_type"] == "client"
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"] == "봇 접근 권한이 없습니다"