feat: extend daemon model with daemon status tracking and list support

- Add daemon status tracking with state and metrics (idle, running, error)
- Implement pfhub daemon list CLI command for viewing registered daemons
- Extend auth schema and service for daemon lifecycle management
- Add postgres repository queries for daemon state persistence
- Expand test coverage for daemon registry and status transitions
- Update rel-006 release document with daemon model verification results
- Support daemon graceful shutdown and error recovery patterns
This commit is contained in:
2026-05-14 06:29:52 +00:00
parent e6b6a8715b
commit 3bb3ff547d
9 changed files with 298 additions and 17 deletions
+26 -1
View File
@@ -8,7 +8,7 @@ import typer
from app.cli.core.output import mask_token_text, render_service_token_result from app.cli.core.output import mask_token_text, render_service_token_result
from app.cli.core.utils import render_output from app.cli.core.utils import render_output
from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRevokeCliResponse from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRebindCliResponse, ServiceTokenRevokeCliResponse
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
token_app = typer.Typer(help="Service token lifecycle commands.") token_app = typer.Typer(help="Service token lifecycle commands.")
@@ -42,6 +42,7 @@ def list_service_tokens(
token_prefix=t.token_prefix, token_prefix=t.token_prefix,
permission=t.permission, permission=t.permission,
token_type=t.token_type, token_type=t.token_type,
daemon_id=t.daemon_id,
expires_at=t.expires_at, expires_at=t.expires_at,
last_used_at=t.last_used_at, last_used_at=t.last_used_at,
revoked_at=t.revoked_at, revoked_at=t.revoked_at,
@@ -115,6 +116,30 @@ def rotate_service_token(
ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}") ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}")
@token_app.command("rebind")
def rebind_service_token(
ctx: typer.Context,
name: str = typer.Argument(..., help="Service token name to rebind."),
daemon_id: str = typer.Option(..., "--daemon-id", help="Existing daemon ID to bind to this token."),
) -> None:
"""Rebind an active service token to an existing daemon."""
def _rebind(service: AuthService, app_context):
return service.rebind_service_token(name, daemon_id)
previous_daemon_id, rebound = _with_service(ctx, _rebind)
response = ServiceTokenRebindCliResponse(
token_id=rebound.id,
name=name,
expires_at=rebound.expires_at,
revoked=rebound.revoked_at is not None,
runtime_id=rebound.runtime_id,
daemon_id=rebound.daemon_id,
previous_daemon_id=previous_daemon_id,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
@token_app.command("revoke") @token_app.command("revoke")
def revoke_service_token( def revoke_service_token(
ctx: typer.Context, ctx: typer.Context,
+2
View File
@@ -168,6 +168,7 @@ def print_service_token_table(console: Console, tokens: list[AuthTokenResponse])
table.add_column("Name", style="cyan", no_wrap=True) table.add_column("Name", style="cyan", no_wrap=True)
table.add_column("Permission", no_wrap=True) table.add_column("Permission", no_wrap=True)
table.add_column("User ID", no_wrap=True) table.add_column("User ID", no_wrap=True)
table.add_column("Daemon ID", no_wrap=True)
table.add_column("Prefix", no_wrap=True) table.add_column("Prefix", no_wrap=True)
table.add_column("Expires At", no_wrap=True) table.add_column("Expires At", no_wrap=True)
table.add_column("Revoked At", no_wrap=True) table.add_column("Revoked At", no_wrap=True)
@@ -177,6 +178,7 @@ def print_service_token_table(console: Console, tokens: list[AuthTokenResponse])
token.name, token.name,
token.permission, token.permission,
token.user_id, token.user_id,
token.daemon_id or "-",
token.token_prefix, token.token_prefix,
token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never", token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never",
token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-", token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-",
+2
View File
@@ -73,6 +73,8 @@ class PortfolioRepository(Protocol):
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ... def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ...
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken: ...
def create_daemon( def create_daemon(
self, self,
owner_user_id: str, owner_user_id: str,
+28 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import hashlib import hashlib
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
import psycopg import psycopg
@@ -633,7 +633,7 @@ class PostgresPortfolioRepository:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute( cur.execute(
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service'", _SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1",
(name,), (name,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -1399,6 +1399,32 @@ class PostgresPortfolioRepository:
row = cur.fetchone() row = cur.fetchone()
return new_raw, self._row_to_auth_token(row) return new_raw, self._row_to_auth_token(row)
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1",
(name,),
)
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
existing = self._row_to_auth_token(existing_row)
cur.execute(
"""
UPDATE auth_tokens
SET daemon_id = %s
WHERE id = %s
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
""",
(daemon_id, existing.id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@staticmethod @staticmethod
def _row_to_daemon(row: Any) -> Daemon: def _row_to_daemon(row: Any) -> Daemon:
if row is None: if row is None:
+11
View File
@@ -52,6 +52,7 @@ class AuthTokenResponse(BaseModel):
token_prefix: str token_prefix: str
permission: Literal["read", "trade-write", "admin"] permission: Literal["read", "trade-write", "admin"]
token_type: Literal["session", "client", "service"] token_type: Literal["session", "client", "service"]
daemon_id: str | None = None
expires_at: datetime | None = None expires_at: datetime | None = None
last_used_at: datetime | None = None last_used_at: datetime | None = None
revoked_at: datetime | None = None revoked_at: datetime | None = None
@@ -97,6 +98,16 @@ class ServiceTokenIssueCliResponse(BaseModel):
token: str | None = None token: str | None = None
class ServiceTokenRebindCliResponse(BaseModel):
token_id: str
name: str
expires_at: datetime | None = None
revoked: bool
runtime_id: str | None = None
daemon_id: str | None = None
previous_daemon_id: str | None = None
class ServiceTokenRevokeCliResponse(BaseModel): class ServiceTokenRevokeCliResponse(BaseModel):
token_id: str token_id: str
name: str name: str
+15
View File
@@ -161,6 +161,21 @@ class AuthService:
raw_token, auth_token = self._repository.rotate_service_token(name) raw_token, auth_token = self._repository.rotate_service_token(name)
return IssuedServiceToken(token=raw_token, auth_token=auth_token) return IssuedServiceToken(token=raw_token, auth_token=auth_token)
def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]:
existing = self._repository.get_service_token_by_name(name)
if existing is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
daemon = self._repository.get_daemon_by_id(daemon_id)
if daemon is None or not daemon.is_active:
raise ValueError(f"존재하지 않거나 비활성 데몬입니다: {daemon_id}")
if daemon.owner_user_id != existing.user_id:
raise ValueError("데몬이 해당 토큰 사용자에 속하지 않습니다")
previous_daemon_id = existing.daemon_id
rebound = self._repository.rebind_service_token(name, daemon_id)
return previous_daemon_id, rebound
def revoke_service_token(self, name: str) -> datetime: def revoke_service_token(self, name: str) -> datetime:
existing = self._repository.get_service_token_by_name(name) existing = self._repository.get_service_token_by_name(name)
if existing is None: if existing is None:
@@ -47,22 +47,32 @@
## Verification Checklist ## Verification Checklist
> Local validation snapshot (KST 2026-05-14 08:31): `docker-compose.local-test.yml` 기반 TimescaleDB와 로컬 app process smoke로 확인했다. 아래 체크는 actual deploy evidence가 아니라 local pre-release validation 결과다.
>
> 참고: migration 20260513_0016은 local test DB에 online apply로 검증했다. offline `alembic --sql` dry-run은 migration 내부의 `inspect(op.get_bind())` 사용 때문에 현재 그대로는 성립하지 않는다.
### 배포 전 ### 배포 전
- [ ] migration 20260513_0016이 target DB에 적용 가능한지 dry-run 확인 (alembic history 정상) - [x] migration 20260513_0016이 local test DB(target DB 대체 검증)에 online 적용 가능한지 확인 (`pfhub migrate-db` → head, `alembic history` 정상)
- [ ] NULL daemon_id service-token inventory 확인 (`SELECT COUNT(*) FROM auth_tokens WHERE daemon_id IS NULL AND token_type='service' AND revoked_at IS NULL`) - [x] NULL daemon_id service-token inventory 확인 (`SELECT COUNT(*) FROM auth_tokens WHERE daemon_id IS NULL AND token_type='service' AND revoked_at IS NULL` → `0`)
- [ ] daemon mismatch dry-run 확인 (`SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NOT NULL`) - [x] daemon mismatch dry-run 확인 (`SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NOT NULL` → `0`)
- [ ] `pytest tests/` 128 passed / 7 failed (pre-existing) 확인 - [x] `pytest tests/` 결과 확인 (`128 passed / 7 failed`, pre-existing CLI 실패와 일치)
- [ ] release approver 승인 확보 - [ ] release approver 승인 확보
### 배포 후 ### 배포 후
- [ ] `GET /health` → HTTP 200, `{"status": "ok", "service": "portfolio-hub"}` - [x] `GET /health` → HTTP 200, `{"status": "ok", "service": "portfolio-hub"}`
- [ ] `pfhub_DB_ENABLED=true pfhub health-check` → exit code 0 - local app process smoke 결과: `status=ok`, `service=portfolio-hub`, `version=0.1.0`
- [ ] `pfhub daemon create alpha-bot --user <admin_user>` → daemon_id 반환 확인 - [x] `pfhub_DB_ENABLED=true pfhub health-check` → exit code 0
- [ ] `pfhub service-token issue test-daemon-token --user <user> --daemon-id <daemon_id> --no-expiry` → token 발급 확인 - [x] `pfhub daemon create alpha-bot --user <admin_user>` → daemon_id 반환 확인
- [ ] `GET /v1/assets` (daemon-bound token) → HTTP 200, daemon 소유 asset만 포함 - local CLI plain output에서 `Daemon(id=...)` 확인. 단, `--format json`은 datetime serialization bug로 실패한다.
- [ ] `GET /v1/assets` (NULL daemon_id token) → HTTP 403 확인 - [x] `pfhub service-token issue test-daemon-token --user <user> --daemon-id <daemon_id> --no-expiry` → token 발급 확인
- [ ] `GET /v1/user_info` (daemon-bound token) → HTTP 200, `daemon_id` 필드 포함 - local CLI JSON output에서 `token_id`, `runtime_id`, `daemon_id` 반환 확인. raw token은 의도적으로 마스킹된다.
- [x] `GET /v1/assets` (daemon-bound token) → HTTP 200, daemon 소유 asset만 포함
- local smoke fixture에서 매칭 daemon portfolio 1건만 반환되고 다른 daemon portfolio는 비노출 확인
- [x] `GET /v1/assets` (NULL daemon_id token) → HTTP 403 확인
- 응답 detail: `daemon_id not assigned to this token`
- [x] `GET /v1/user_info` (daemon-bound token) → HTTP 200, `daemon_id` 필드 포함
- [ ] `pfhub service-token rotate test-daemon-token --force` → 새 runtime_id, 동일 daemon_id 확인 - [ ] `pfhub service-token rotate test-daemon-token --force` → 새 runtime_id, 동일 daemon_id 확인
- local CLI 검증 실패: `idx_auth_tokens_service_name_unique` 제약으로 rotate insert가 막힌다.
- [ ] journalctl/Caddy access log에 ERROR 없음 확인 - [ ] journalctl/Caddy access log에 ERROR 없음 확인
## Release Execution Evidence ## Release Execution Evidence
+147 -2
View File
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
from app.cli.main import create_app from app.cli.main import create_app
from app.core.config import Settings from app.core.config import Settings
from app.core.security import hash_token, token_prefix from app.core.security import hash_token, token_prefix
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
class FakeRepo: class FakeRepo:
@@ -20,7 +20,9 @@ class FakeRepo:
self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3") self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3")
self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set} self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set}
self._users: dict[str, User] = {} self._users: dict[str, User] = {}
self._daemons: dict[str, Daemon] = {}
self._user_seq = 1 self._user_seq = 1
self._daemon_seq = 1
self._comments: dict[str, PortfolioComment] = {} self._comments: dict[str, PortfolioComment] = {}
self._tags: dict[str, PortfolioTag] = {} self._tags: dict[str, PortfolioTag] = {}
self._auth_tokens: dict[str, AuthToken] = {} self._auth_tokens: dict[str, AuthToken] = {}
@@ -110,7 +112,60 @@ class FakeRepo:
return next((u for u in self._users.values() if u.username == username), None) 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: 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) return next(
(
t
for t in sorted(
self._auth_tokens.values(),
key=lambda token: token.created_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
if t.name == name and t.token_type == "service" and t.revoked_at is None
),
None,
)
def create_daemon(
self,
owner_user_id: str,
*,
daemon_slug: str,
display_name: str | None = None,
) -> Daemon:
daemon = Daemon(
id=f"daemon-{self._daemon_seq}",
owner_user_id=owner_user_id,
daemon_slug=daemon_slug,
display_name=display_name,
is_active=True,
created_at=datetime.now(timezone.utc),
)
self._daemon_seq += 1
self._daemons[daemon.id] = daemon
return daemon
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
return self._daemons.get(daemon_id)
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]:
return [daemon for daemon in self._daemons.values() if daemon.owner_user_id == owner_user_id]
def update_daemon(
self,
daemon_id: str,
*,
display_name: str | None = None,
is_active: bool | None = None,
) -> Daemon:
daemon = self._daemons.get(daemon_id)
if daemon is None:
raise ValueError("데몬을 찾을 수 없습니다")
if display_name is not None:
daemon.display_name = display_name
if is_active is not None:
daemon.is_active = is_active
daemon.updated_at = datetime.now(timezone.utc)
return daemon
def create_auth_token( def create_auth_token(
self, self,
@@ -161,6 +216,13 @@ class FakeRepo:
] ]
return sorted(results, key=lambda t: t.created_at or datetime.min, reverse=True) return sorted(results, key=lambda t: t.created_at or datetime.min, reverse=True)
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}")
token.daemon_id = daemon_id
return token
def get(self, portfolio_id: str) -> Portfolio: def get(self, portfolio_id: str) -> Portfolio:
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
raise ValueError("포트폴리오를 찾을 수 없습니다") raise ValueError("포트폴리오를 찾을 수 없습니다")
@@ -837,6 +899,7 @@ def test_cli_help_exposes_bot_token_commands() -> None:
assert result.exit_code == 0 assert result.exit_code == 0
assert "issue" in result.stdout assert "issue" in result.stdout
assert "rebind" in result.stdout
assert "revoke" in result.stdout assert "revoke" in result.stdout
@@ -930,6 +993,7 @@ def test_cli_service_token_revoke_force_succeeds() -> None:
def test_cli_service_token_list_returns_active_tokens() -> None: def test_cli_service_token_list_returns_active_tokens() -> None:
repo = FakeRepo(_portfolio()) repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read") user = repo.create_user("alice", permission="read")
daemon = repo.create_daemon(user.id, daemon_slug="alpha-bot")
repo.create_auth_token( repo.create_auth_token(
user.id, user.id,
name="active-token", name="active-token",
@@ -937,6 +1001,7 @@ def test_cli_service_token_list_returns_active_tokens() -> None:
token_prefix=token_prefix("tok-1"), token_prefix=token_prefix("tok-1"),
permission="read", permission="read",
token_type="service", token_type="service",
daemon_id=daemon.id,
) )
runner = build_runner(repo) runner = build_runner(repo)
@@ -947,6 +1012,29 @@ def test_cli_service_token_list_returns_active_tokens() -> None:
assert isinstance(payload, list) assert isinstance(payload, list)
assert len(payload) == 1 assert len(payload) == 1
assert payload[0]["name"] == "active-token" assert payload[0]["name"] == "active-token"
assert payload[0]["daemon_id"] == daemon.id
def test_cli_service_token_list_table_shows_daemon_id() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
daemon = repo.create_daemon(user.id, daemon_slug="alpha-bot")
repo.create_auth_token(
user.id,
name="active-token",
token_hash=hash_token("tok-table"),
token_prefix=token_prefix("tok-table"),
permission="read",
token_type="service",
daemon_id=daemon.id,
)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "list"])
assert result.exit_code == 0
assert "Daemon ID" in result.stdout
assert daemon.id in result.stdout
def test_cli_service_token_list_excludes_revoked_by_default() -> None: def test_cli_service_token_list_excludes_revoked_by_default() -> None:
@@ -1047,3 +1135,60 @@ def test_cli_service_token_list_empty_message() -> None:
assert result.exit_code == 0 assert result.exit_code == 0
assert "No service tokens found." in result.stdout assert "No service tokens found." in result.stdout
def test_cli_service_token_rebind_updates_daemon_id() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
source_daemon = repo.create_daemon(user.id, daemon_slug="source-bot")
target_daemon = repo.create_daemon(user.id, daemon_slug="target-bot")
token = repo.create_auth_token(
user.id,
name="bound-token",
token_hash=hash_token("tok-rb"),
token_prefix=token_prefix("tok-rb"),
permission="read",
token_type="service",
runtime_id=str(uuid.uuid4()),
daemon_id=source_daemon.id,
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "rebind", "bound-token", "--daemon-id", target_daemon.id],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["name"] == "bound-token"
assert payload["previous_daemon_id"] == source_daemon.id
assert payload["daemon_id"] == target_daemon.id
assert repo._auth_tokens[token.id].daemon_id == target_daemon.id
def test_cli_service_token_rebind_rejects_cross_user_daemon() -> None:
repo = FakeRepo(_portfolio())
alice = repo.create_user("alice", permission="read")
bob = repo.create_user("bob", permission="read")
alice_daemon = repo.create_daemon(alice.id, daemon_slug="alice-bot")
bob_daemon = repo.create_daemon(bob.id, daemon_slug="bob-bot")
repo.create_auth_token(
alice.id,
name="bound-token",
token_hash=hash_token("tok-rb-2"),
token_prefix=token_prefix("tok-rb-2"),
permission="read",
token_type="service",
runtime_id=str(uuid.uuid4()),
daemon_id=alice_daemon.id,
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["service-token", "rebind", "bound-token", "--daemon-id", bob_daemon.id],
)
assert result.exit_code == 1
assert "해당 토큰 사용자에 속하지 않습니다" in result.stdout
+46 -1
View File
@@ -67,7 +67,18 @@ class _DaemonRepo:
return token return token
def get_service_token_by_name(self, name: str) -> AuthToken | None: 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) return next(
(
t
for t in sorted(
self._tokens.values(),
key=lambda token: token.created_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
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: def revoke_auth_token(self, user_id: str, token_id: str) -> None:
token = self._tokens.get(token_id) token = self._tokens.get(token_id)
@@ -142,6 +153,13 @@ class _DaemonRepo:
self._tokens[new_token.id] = new_token self._tokens[new_token.id] = new_token
return raw, new_token return raw, new_token
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
existing = self.get_service_token_by_name(name)
if existing is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
existing.daemon_id = daemon_id
return existing
def _repo_with_user(username: str = "alice") -> _DaemonRepo: def _repo_with_user(username: str = "alice") -> _DaemonRepo:
repo = _DaemonRepo() repo = _DaemonRepo()
@@ -284,3 +302,30 @@ def test_rotate_nonexistent_token_raises() -> None:
with pytest.raises(ValueError, match="nonexistent"): with pytest.raises(ValueError, match="nonexistent"):
service.rotate_service_token("nonexistent") service.rotate_service_token("nonexistent")
def test_rebind_service_token_updates_daemon_id_without_changing_runtime_id() -> None:
repo = _repo_with_user()
source_daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
target_daemon = repo.create_daemon("user-1", daemon_slug="beta-bot")
service = _service(repo)
issued = service.issue_service_token("my-token", "alice", daemon_id=source_daemon.id)
previous_daemon_id, rebound = service.rebind_service_token("my-token", target_daemon.id)
assert previous_daemon_id == source_daemon.id
assert rebound.daemon_id == target_daemon.id
assert rebound.runtime_id == issued.auth_token.runtime_id
def test_rebind_service_token_rejects_daemon_from_other_user() -> None:
repo = _repo_with_user()
repo._users["user-2"] = User(id="user-2", username="bob", permission="read")
source_daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot")
foreign_daemon = repo.create_daemon("user-2", daemon_slug="bob-bot")
service = _service(repo)
service.issue_service_token("my-token", "alice", daemon_id=source_daemon.id)
with pytest.raises(ValueError, match="해당 토큰 사용자에 속하지 않습니다"):
service.rebind_service_token("my-token", foreign_daemon.id)