Add admin user management
This commit is contained in:
+92
-2
@@ -8,7 +8,7 @@ from typer.testing import CliRunner
|
||||
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, StrategySet
|
||||
from app.domain.models import LedgerEntry, Portfolio, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -17,6 +17,8 @@ class FakeRepo:
|
||||
self.entries: list[LedgerEntry] = []
|
||||
self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3")
|
||||
self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set}
|
||||
self._users: dict[str, User] = {}
|
||||
self._user_seq = 1
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -28,6 +30,50 @@ class FakeRepo:
|
||||
def ping(self) -> None:
|
||||
return None
|
||||
|
||||
def get_user(self, user_id: str) -> User:
|
||||
user = self._users.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
return user
|
||||
|
||||
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) -> 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._user_seq}",
|
||||
username=username,
|
||||
email=email,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._user_seq += 1
|
||||
self._users[user.id] = user
|
||||
return user
|
||||
|
||||
def update_user(self, user_id: str, *, username: str | None = None, email: str | 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}")
|
||||
if username is not None:
|
||||
user.username = username
|
||||
if email is not None:
|
||||
user.email = email
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
return user
|
||||
|
||||
def delete_user(self, user_id: str) -> None:
|
||||
if user_id not in self._users:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
del self._users[user_id]
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
@@ -392,4 +438,48 @@ def test_cli_migrate_db_reports_head(monkeypatch) -> None:
|
||||
result = runner.invoke(runner.app, ["migrate-db"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "migration_applied: head" in result.stdout
|
||||
assert "migration_applied: head" in result.stdout
|
||||
|
||||
|
||||
def test_cli_user_list_renders_table() -> None:
|
||||
repo = FakeRepo()
|
||||
repo.create_user("alice", email="alice@example.com")
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["user", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Users" in result.stdout
|
||||
assert "alice" in result.stdout
|
||||
|
||||
|
||||
def test_cli_user_create_supports_json() -> None:
|
||||
repo = FakeRepo()
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "user", "create", "--username", "alice", "--email", "alice@example.com", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["username"] == "alice"
|
||||
assert payload["email"] == "alice@example.com"
|
||||
|
||||
|
||||
def test_cli_user_update_and_delete_flow() -> None:
|
||||
repo = FakeRepo()
|
||||
user = repo.create_user("alice", email="alice@example.com")
|
||||
runner = build_runner(repo)
|
||||
|
||||
update_result = runner.invoke(
|
||||
runner.app,
|
||||
["user", "update", user.id, "--username", "alice-updated", "--force"],
|
||||
)
|
||||
delete_result = runner.invoke(runner.app, ["user", "delete", user.id, "--force"])
|
||||
|
||||
assert update_result.exit_code == 0
|
||||
assert "alice-updated" in update_result.stdout
|
||||
assert delete_result.exit_code == 0
|
||||
assert f"Deleted user: {user.id}" in delete_result.stdout
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.domain.models import User
|
||||
from app.main import app
|
||||
from app.services.user_service import UserService, get_user_service
|
||||
|
||||
|
||||
class FakeUserRepo:
|
||||
def __init__(self) -> None:
|
||||
self.users: dict[str, User] = {}
|
||||
self.next_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 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) -> 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,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.next_id += 1
|
||||
self.users[user.id] = user
|
||||
return user
|
||||
|
||||
def update_user(self, user_id: str, *, username: str | None = None, email: str | 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}")
|
||||
if username is not None:
|
||||
user.username = username
|
||||
if email is not None:
|
||||
user.email = email
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
return user
|
||||
|
||||
def delete_user(self, user_id: str) -> None:
|
||||
if user_id not in self.users:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
del self.users[user_id]
|
||||
|
||||
|
||||
def _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
||||
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)
|
||||
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")
|
||||
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")
|
||||
client = _client_with_repo(monkeypatch, repo)
|
||||
|
||||
response = client.post(
|
||||
"/users",
|
||||
headers={"Authorization": "Bearer admin-token"},
|
||||
json={"username": "alice", "email": "alice2@example.com"},
|
||||
)
|
||||
|
||||
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")
|
||||
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
|
||||
Reference in New Issue
Block a user