Files
portfolio-hub/tests/test_auth.py
T
genichin 44f2102d26 feat: consolidate ledger trade writes into portfolio_ledger
- add ledger consolidation migration and remove ledger_trades usage
- return portfolio state after v1 ledger trade writes
- deprecate legacy buy/sell endpoints with deprecation headers
- add BAT-016, DCY-006, and REQ-036~038 documentation
- update repository models and tests for unified ledger entries
2026-06-05 20:55:24 +09:00

335 lines
11 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
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
from app.main import app
from app.services.auth_service import AuthService
def _client_with_tokens() -> TestClient:
get_settings.cache_clear()
app.dependency_overrides.clear()
return TestClient(app)
def test_read_route_requires_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
get_settings.cache_clear()
client = _client_with_tokens()
response = client.get("/portfolios")
assert response.status_code == 401
assert response.json()["detail"] == "Missing bearer token"
def test_read_route_accepts_read_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
get_settings.cache_clear()
client = _client_with_tokens()
response = client.get("/portfolios", headers={"Authorization": "Bearer read-token"})
assert response.status_code == 501
assert response.json()["detail"] == "Portfolio persistence is not implemented yet"
def test_trade_route_rejects_read_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "trade-token")
get_settings.cache_clear()
client = _client_with_tokens()
response = client.post(
"/portfolios/p1/buy",
headers={"Authorization": "Bearer read-token"},
json={"quantity": 1, "price": 1000, "fee": 0, "tax": 0, "note": ""},
)
assert response.status_code == 403
assert response.json()["detail"] == "Insufficient permission"
def test_legacy_buy_sell_routes_emit_deprecation_header(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "trade-token")
get_settings.cache_clear()
client = _client_with_tokens()
for path in ("/portfolios/p1/buy", "/portfolios/p1/sell"):
response = client.post(
path,
headers={"Authorization": "Bearer trade-token"},
json={"quantity": 1, "price": 1000, "fee": 0, "tax": 0, "note": ""},
)
assert response.status_code == 501
assert response.headers["Deprecation"] == "true"
def test_admin_route_accepts_admin_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
get_settings.cache_clear()
client = _client_with_tokens()
response = client.delete("/portfolios/p1", headers={"Authorization": "Bearer admin-token"})
assert response.status_code == 501
assert response.json()["detail"] == "Portfolio persistence is not implemented yet"
@dataclass
class _AuthRepo:
def __post_init__(self) -> None:
self.users: dict[str, object] = {}
self.tokens: dict[str, AuthToken] = {}
self._daemons: dict[str, object] = {}
self._seq = 1
def get_user_by_username(self, username: str):
return self.users.get(username)
def get_daemon_by_id(self, daemon_id: str):
return self._daemons.get(daemon_id)
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,
user_id: str,
*,
name: str,
token_hash: str,
token_prefix: str,
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken:
token = AuthToken(
id=f"token-{self._seq}",
user_id=user_id,
name=name,
token_prefix=token_prefix,
permission=permission,
token_type=token_type,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
runtime_id=runtime_id,
daemon_id=daemon_id,
)
self._seq += 1
self.tokens[token.id] = token
return token
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 _service(repo: _AuthRepo) -> AuthService:
return AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"))
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_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"
assert result.auth_token.runtime_id is not None
def test_issue_service_token_generates_distinct_runtime_ids() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
service = _service(repo)
first = service.issue_service_token("my-token-1", "alice")
second = service.issue_service_token("my-token-2", "alice")
assert first.auth_token.runtime_id is not None
assert second.auth_token.runtime_id is not None
assert first.auth_token.runtime_id != second.auth_token.runtime_id
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)
try:
service.issue_service_token("my-token", "unknown")
except ValueError as exc:
assert "unknown" in str(exc)
else:
raise AssertionError("Expected ValueError for unknown user")
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.revoke_service_token("nonexistent")
except ValueError as exc:
assert "nonexistent" in str(exc)
else:
raise AssertionError("Expected ValueError for missing service token")
# REQ-018: daemon_id forward 보정 (service layer)
def test_issue_service_token_with_daemon_id_stores_it() -> None:
from app.domain.models import Daemon, User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
daemon = Daemon(
id="daemon-1",
owner_user_id="user-1",
daemon_slug="my-daemon",
is_active=True,
created_at=datetime.now(timezone.utc),
)
repo._daemons = {"daemon-1": daemon}
service = _service(repo)
result = service.issue_service_token("tok", "alice", no_expiry=True, daemon_id="daemon-1")
assert result.auth_token.daemon_id == "daemon-1"
def test_issue_service_token_with_nonexistent_daemon_raises() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
repo._daemons = {}
service = _service(repo)
try:
service.issue_service_token("tok", "alice", no_expiry=True, daemon_id="no-such-daemon")
except ValueError as exc:
assert "no-such-daemon" in str(exc)
else:
raise AssertionError("Expected ValueError for nonexistent daemon")
def test_issue_service_token_without_daemon_id_is_unbound() -> 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("tok", "alice", no_expiry=True)
assert result.auth_token.daemon_id is None
# REQ-016: POST /auth/tokens 제거 — HTTP AC-2
def test_post_auth_tokens_returns_404(monkeypatch) -> None:
import os
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
from app.core.config import get_settings as _get_settings
_get_settings.cache_clear()
from app.main import app as _app
client = TestClient(_app)
response = client.post(
"/auth/tokens",
headers={"Authorization": "Bearer read-token"},
json={"name": "test", "permission": "read"},
)
_app.dependency_overrides.clear()
assert response.status_code in (404, 405)
# REQ-034: LoginRequest extra="forbid" enforcement (dcy-005 FR-2)
def test_login_rejects_no_expiry_extra_field(monkeypatch) -> None:
"""REQ-034 AC-2: session 발급 경로에 no_expiry 시도 → HTTP 422 (extra="forbid")."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
get_settings.cache_clear()
client = TestClient(app)
resp = client.post(
"/auth/login",
json={"username": "anyone", "password": "any", "no_expiry": True},
)
assert resp.status_code == 422
body = resp.json()
assert any("extra" in (err.get("type") or "") for err in body.get("detail", []))
def test_login_rejects_arbitrary_extra_field(monkeypatch) -> None:
"""REQ-034 AC-3: 임의 추가 필드 → HTTP 422 (extra="forbid" 일반 동작)."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
get_settings.cache_clear()
client = TestClient(app)
resp = client.post(
"/auth/login",
json={"username": "anyone", "password": "any", "arbitrary_field": "x"},
)
assert resp.status_code == 422