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"] == "봇 접근 권한이 없습니다"