작업 내용 저장

This commit is contained in:
2026-04-23 09:56:08 +00:00
parent 514b49cb98
commit d383c7086f
29 changed files with 1654 additions and 1372 deletions
+55 -72
View File
@@ -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
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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"] == "봇 접근 권한이 없습니다"