feat: dcy-003/004 토큰 surface 개편 및 daemon lifecycle 정책 구현 (BAT-006/007)
BAT-006 (REQ-016/017/018): - client token 발급 경로 제거 (issue_client_token, POST /auth/tokens, 관련 스키마/테스트) - service-token rotate unique index revoked_at IS NULL 조건 추가 (Alembic CONCURRENTLY 마이그레이션) - POST /auth/service-tokens body daemon_id forward 누락 보정 (1-line fix) BAT-007 (REQ-022/023): - daemon deactivate 시 바인딩 service token 자동 revoke (CLI --force 옵션 + revoke_tokens_by_daemon_id) - NULL daemon_id legacy token 진단 CLI (migrate-null-daemon --dry-run) 거버넌스 문서: dcy-003/004 Discovery, REQ-016~023/025~032 신규, BAT-006/007 전체 산출물 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,11 +77,15 @@ 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":
|
||||
@@ -208,3 +212,74 @@ def test_revoke_service_token_not_found_raises() -> None:
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""REQ-016: client token 발급 경로 폐지 후 표면 검증 (AC-1, AC-4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
# REQ-016 AC-1: codebase에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음
|
||||
|
||||
def test_no_issue_client_token_symbol_in_codebase() -> None:
|
||||
result = subprocess.run(
|
||||
["grep", "-r", "--include=*.py", "-l", "issue_client_token"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
|
||||
)
|
||||
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
|
||||
assert matches == [], f"issue_client_token 잔여 참조 발견: {matches}"
|
||||
|
||||
|
||||
def test_no_create_client_token_request_symbol_in_codebase() -> None:
|
||||
result = subprocess.run(
|
||||
["grep", "-r", "--include=*.py", "-l", "CreateClientTokenRequest"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
|
||||
)
|
||||
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
|
||||
assert matches == [], f"CreateClientTokenRequest 잔여 참조 발견: {matches}"
|
||||
|
||||
|
||||
def test_no_issued_client_token_response_symbol_in_codebase() -> None:
|
||||
result = subprocess.run(
|
||||
["grep", "-r", "--include=*.py", "-l", "IssuedClientTokenResponse"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
|
||||
)
|
||||
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
|
||||
assert matches == [], f"IssuedClientTokenResponse 잔여 참조 발견: {matches}"
|
||||
|
||||
|
||||
# REQ-016 AC-4: dcy-003 token_type 게이트 — client token 발급 불가에 따른 회귀 없음
|
||||
# (token_type='client' 게이트 자체는 유지: dcy-003 OQ-5)
|
||||
|
||||
def test_client_token_type_not_importable_from_auth_service() -> None:
|
||||
import importlib
|
||||
module = importlib.import_module("app.services.auth_service")
|
||||
assert not hasattr(module, "IssuedClientToken"), "IssuedClientToken이 auth_service에 잔존합니다"
|
||||
assert not hasattr(module.AuthService, "issue_client_token"), "issue_client_token 메서드가 잔존합니다"
|
||||
|
||||
|
||||
def test_client_token_request_not_importable_from_schemas() -> None:
|
||||
import importlib
|
||||
module = importlib.import_module("app.schemas.auth")
|
||||
assert not hasattr(module, "CreateClientTokenRequest"), "CreateClientTokenRequest가 schemas/auth에 잔존합니다"
|
||||
assert not hasattr(module, "IssuedClientTokenResponse"), "IssuedClientTokenResponse가 schemas/auth에 잔존합니다"
|
||||
@@ -160,6 +160,20 @@ class _DaemonRepo:
|
||||
existing.daemon_id = daemon_id
|
||||
return existing
|
||||
|
||||
def revoke_tokens_by_daemon_id(self, daemon_id: str) -> int:
|
||||
count = 0
|
||||
for token in self._tokens.values():
|
||||
if token.daemon_id == daemon_id and token.revoked_at is None:
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]:
|
||||
return [
|
||||
t for t in self._tokens.values()
|
||||
if t.token_type == "service" and t.daemon_id is None and t.revoked_at is None
|
||||
]
|
||||
|
||||
|
||||
def _repo_with_user(username: str = "alice") -> _DaemonRepo:
|
||||
repo = _DaemonRepo()
|
||||
@@ -329,3 +343,73 @@ def test_rebind_service_token_rejects_daemon_from_other_user() -> None:
|
||||
|
||||
with pytest.raises(ValueError, match="해당 토큰 사용자에 속하지 않습니다"):
|
||||
service.rebind_service_token("my-token", foreign_daemon.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-022: daemon deactivation 시 바인딩 service token 자동 revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_deactivate_daemon_revokes_bound_tokens() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
service = _service(repo)
|
||||
issued = service.issue_service_token("tok1", "alice", daemon_id=daemon.id, no_expiry=True)
|
||||
|
||||
repo.update_daemon(daemon.id, is_active=False)
|
||||
revoked_count = repo.revoke_tokens_by_daemon_id(daemon.id)
|
||||
|
||||
assert revoked_count == 1
|
||||
assert repo._tokens[issued.auth_token.id].revoked_at is not None
|
||||
assert not repo._daemons[daemon.id].is_active
|
||||
|
||||
|
||||
def test_deactivate_daemon_revokes_multiple_bound_tokens() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
service = _service(repo)
|
||||
issued1 = service.issue_service_token("tok1", "alice", daemon_id=daemon.id, no_expiry=True)
|
||||
issued2 = service.issue_service_token("tok2", "alice", daemon_id=daemon.id, no_expiry=True)
|
||||
|
||||
repo.update_daemon(daemon.id, is_active=False)
|
||||
revoked_count = repo.revoke_tokens_by_daemon_id(daemon.id)
|
||||
|
||||
assert revoked_count == 2
|
||||
assert repo._tokens[issued1.auth_token.id].revoked_at is not None
|
||||
assert repo._tokens[issued2.auth_token.id].revoked_at is not None
|
||||
|
||||
|
||||
def test_deactivate_daemon_with_no_tokens_is_noop() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
|
||||
repo.update_daemon(daemon.id, is_active=False)
|
||||
revoked_count = repo.revoke_tokens_by_daemon_id(daemon.id)
|
||||
|
||||
assert revoked_count == 0
|
||||
assert not repo._daemons[daemon.id].is_active
|
||||
|
||||
|
||||
def test_reactivate_daemon_does_not_unrevoke_tokens() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
service = _service(repo)
|
||||
issued = service.issue_service_token("tok1", "alice", daemon_id=daemon.id, no_expiry=True)
|
||||
|
||||
repo.update_daemon(daemon.id, is_active=False)
|
||||
repo.revoke_tokens_by_daemon_id(daemon.id)
|
||||
repo.update_daemon(daemon.id, is_active=True)
|
||||
|
||||
assert repo._daemons[daemon.id].is_active
|
||||
assert repo._tokens[issued.auth_token.id].revoked_at is not None
|
||||
|
||||
|
||||
def test_revoke_tokens_for_daemon_via_service() -> None:
|
||||
repo = _repo_with_user()
|
||||
daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
|
||||
service = _service(repo)
|
||||
issued = service.issue_service_token("tok1", "alice", daemon_id=daemon.id, no_expiry=True)
|
||||
|
||||
count = service.revoke_tokens_for_daemon(daemon.id)
|
||||
|
||||
assert count == 1
|
||||
assert repo._tokens[issued.auth_token.id].revoked_at is not None
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""REQ-023: NULL daemon_id legacy service token 마이그레이션 CLI 검증 (AC-1~5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import AuthToken, Daemon, User
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class _FakeRepo:
|
||||
def __post_init__(self) -> None:
|
||||
self._users: dict[str, User] = {}
|
||||
self._tokens: dict[str, AuthToken] = {}
|
||||
self._daemons: dict[str, Daemon] = {}
|
||||
self._seq = 1
|
||||
|
||||
def ping(self) -> None:
|
||||
pass
|
||||
|
||||
def ensure_schema(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def get_user_by_username(self, username: str) -> User | None:
|
||||
return next((u for u in self._users.values() if u.username == username), None)
|
||||
|
||||
def get_user(self, user_id: str) -> User:
|
||||
u = self._users.get(user_id)
|
||||
if u is None:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
return u
|
||||
|
||||
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 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" and t.revoked_at is None),
|
||||
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.user_id != user_id:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]:
|
||||
return [
|
||||
t for t in self._tokens.values()
|
||||
if t.token_type == "service" and t.daemon_id is None and t.revoked_at is None
|
||||
]
|
||||
|
||||
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]:
|
||||
return [d for d in self._daemons.values() if d.owner_user_id == owner_user_id]
|
||||
|
||||
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
|
||||
token = self.get_service_token_by_name(name)
|
||||
if token is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
daemon = self._daemons.get(daemon_id)
|
||||
if daemon is None or not daemon.is_active:
|
||||
raise ValueError(f"존재하지 않거나 비활성 데몬입니다: {daemon_id}")
|
||||
if daemon.owner_user_id != token.user_id:
|
||||
raise ValueError("데몬이 해당 토큰 사용자에 속하지 않습니다")
|
||||
token.daemon_id = daemon_id
|
||||
return token
|
||||
|
||||
def revoke_tokens_by_daemon_id(self, daemon_id: str) -> int:
|
||||
count = 0
|
||||
for t in self._tokens.values():
|
||||
if t.daemon_id == daemon_id and t.revoked_at is None:
|
||||
t.revoked_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
|
||||
return self._daemons.get(daemon_id)
|
||||
|
||||
|
||||
def _make_runner(repo: _FakeRepo) -> CliRunner:
|
||||
app = create_app(
|
||||
settings_factory=lambda: Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"),
|
||||
repository_factory=lambda _: repo,
|
||||
)
|
||||
runner = CliRunner()
|
||||
runner.app = app
|
||||
return runner
|
||||
|
||||
|
||||
def _add_null_daemon_token(repo: _FakeRepo, name: str = "orphan-tok", user_id: str = "user-1") -> AuthToken:
|
||||
from app.core.security import generate_token, hash_token, token_prefix
|
||||
raw = generate_token()
|
||||
return repo.create_auth_token(
|
||||
user_id,
|
||||
name=name,
|
||||
token_hash=hash_token(raw),
|
||||
token_prefix=token_prefix(raw),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
daemon_id=None,
|
||||
runtime_id="rt-1",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-023 AC-1: dry-run with NULL daemon tokens → inventory 출력
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrate_null_daemon_dry_run_shows_inventory() -> None:
|
||||
repo = _FakeRepo()
|
||||
repo._users["user-1"] = User(id="user-1", username="alice", permission="read")
|
||||
token = _add_null_daemon_token(repo, name="orphan-tok", user_id="user-1")
|
||||
runner = _make_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["service-token", "migrate-null-daemon", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "orphan-tok" in result.output
|
||||
assert token.id in result.output or "user-1" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-023 AC-2: dry-run with 0 NULL daemon tokens → "no NULL daemon tokens"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrate_null_daemon_dry_run_no_tokens_prints_empty_message() -> None:
|
||||
repo = _FakeRepo()
|
||||
runner = _make_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["service-token", "migrate-null-daemon", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "no NULL daemon tokens" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-023 AC-3: --dry-run 없이 실행 → non-zero exit + 수동 안내
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrate_null_daemon_without_dry_run_exits_nonzero() -> None:
|
||||
repo = _FakeRepo()
|
||||
runner = _make_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["service-token", "migrate-null-daemon"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "rebind" in result.output or "revoke" in result.output or "dry-run" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-023 AC-4: rebind 후 dry-run에서 해당 token 제외
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrate_null_daemon_after_rebind_token_excluded_from_inventory() -> None:
|
||||
repo = _FakeRepo()
|
||||
repo._users["user-1"] = User(id="user-1", username="alice", permission="read")
|
||||
daemon = Daemon(
|
||||
id="daemon-1",
|
||||
owner_user_id="user-1",
|
||||
daemon_slug="alpha",
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
repo._daemons["daemon-1"] = daemon
|
||||
_add_null_daemon_token(repo, name="orphan-tok", user_id="user-1")
|
||||
service = AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"))
|
||||
service.rebind_service_token("orphan-tok", "daemon-1")
|
||||
|
||||
null_tokens = repo.list_service_tokens_with_null_daemon()
|
||||
|
||||
assert null_tokens == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-023 AC-5: revoke 후 dry-run에서 해당 token 제외
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrate_null_daemon_after_revoke_token_excluded_from_inventory() -> None:
|
||||
repo = _FakeRepo()
|
||||
repo._users["user-1"] = User(id="user-1", username="alice", permission="read")
|
||||
token = _add_null_daemon_token(repo, name="admin-fix", user_id="user-1")
|
||||
service = AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"))
|
||||
service.revoke_service_token("admin-fix")
|
||||
|
||||
null_tokens = repo.list_service_tokens_with_null_daemon()
|
||||
|
||||
assert null_tokens == []
|
||||
assert repo._tokens[token.id].revoked_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-023: dry-run 출력에 매핑 후보 daemon 포함 여부
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migrate_null_daemon_dry_run_shows_candidate_daemon() -> None:
|
||||
repo = _FakeRepo()
|
||||
repo._users["user-1"] = User(id="user-1", username="alice", permission="read")
|
||||
daemon = Daemon(
|
||||
id="daemon-1",
|
||||
owner_user_id="user-1",
|
||||
daemon_slug="alpha",
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
repo._daemons["daemon-1"] = daemon
|
||||
_add_null_daemon_token(repo, name="orphan-tok", user_id="user-1")
|
||||
runner = _make_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["service-token", "migrate-null-daemon", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "daemon-1" in result.output
|
||||
@@ -236,32 +236,3 @@ def test_get_update_and_delete_user_flow(monkeypatch) -> None:
|
||||
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"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user