Core Implementation (BAT-010):
- Add daemon registry schema and CRUD operations (req-023)
- Implement single-daemon asset assignment constraint (one asset per daemon)
- Add daemon_id field to service_token for daemon identification
- Add daemon.py CLI module for daemon management commands
- Add 20260513_0016_daemon_model.py migration for daemon tables
API Changes:
- GET /v1/assets: include daemon_id in AssetResponse
- POST /v1/assets/{asset_id}/ledger/trades: require daemon token binding
- GET /v1/user_info: include daemon metadata
- Enhanced service-token rotate to preserve daemon bindings
CLI Commands:
- pfhub daemon register --name <name> --runtime-id <runtime-id>
- pfhub daemon list
- pfhub daemon get <daemon-id>
- pfhub daemon revoke <daemon-id>
Requirements (REQ-023, REQ-028):
- REQ-023: Daemon Registry and Service Token Daemon Binding (Interface, Approved)
* AC-1: daemon CRUD operations
* AC-2: service token daemon binding for POST /v1/assets/{asset_id}/ledger/trades
* AC-3: single daemon per asset (exact-one assignment model)
- REQ-028: README.md Daemon Provisioning Runbook update (Documentation, Approved)
Documentation:
- Add dcy-006 Discovery for daemon-id and single-daemon asset assignment
- Update README.md with Daemon Provisioning Runbook section
- Add BAT-010 batch documentation with planning, implementation, verification
- Update rel-006 release document (daemon model implementation)
- Update data-model.md with daemon and daemon_asset_assignment tables
- Update interface-contract.md with daemon registry APIs
- Update runtime-flows.md with daemon lifecycle flows
Breaking Changes:
- REQ-005~009, REQ-011~012 status reverted to Approved due to revised contract
* daemon_id field addition and exact-one asset assignment requirement
* Existing Implemented evidence invalidated by contract revision
* BAT-001/002/008 re-verification required
Testing:
- Add test_daemon_registry.py for daemon CRUD operations
- Add test_daemon_assignment.py for single-asset assignment constraints
- Update test_auth.py, test_cli.py, test_v1.py for daemon support
- Verify daemon token binding in trade endpoint authorization
Impact:
- Database: 2 new tables (daemons, daemon_asset_assignments)
- Migration: 20260513_0016 creates daemon model schema
- CLI: 4 new daemon subcommands
- API: daemon_id propagation through v1 endpoints
- Verification: BAT-001/002/008 re-verification gates next release
211 lines
6.7 KiB
Python
211 lines
6.7 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_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._seq = 1
|
|
|
|
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,
|
|
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")
|