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:
2026-05-21 01:03:39 +00:00
co-authored by Claude Sonnet 4.6
parent d4b841bc37
commit c815ce39b6
48 changed files with 3573 additions and 117 deletions
+75
View File
@@ -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)