feat: add service token rename command (#40)

* docs: preserve original issue 5 body

* feat: add service token rename command

* test: normalize rich option errors
This commit is contained in:
2026-09-19 21:19:39 +09:00
committed by GitHub
parent 996127f215
commit deaaa96c46
20 changed files with 882 additions and 7 deletions
+1
View File
@@ -19,6 +19,7 @@ def test_branch_verification_has_no_registry_write_authority_or_path():
assert "uv sync --extra dev --locked" in text and "uv run python -m pytest -q" in text
assert "Verify corporate-action atomicity and guarded recovery in PostgreSQL" in text
assert "tests/test_corporate_action_atomic_recovery_postgres.py" in text
assert "tests/test_service_token_rename_postgres.py" in text
assert "${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres" in text
assert "***@" not in text
+91 -1
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi.testclient import TestClient
import psycopg.errors
from app.core.auth import AuthContext, Permission
from app.core.config import Settings
@@ -96,6 +97,7 @@ class _AuthRepo:
self.tokens: dict[str, AuthToken] = {}
self._daemons: dict[str, object] = {}
self._seq = 1
self.rename_calls = 0
def get_user_by_username(self, username: str):
return self.users.get(username)
@@ -105,10 +107,20 @@ class _AuthRepo:
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":
if token.name == name and token.token_type == "service" and token.revoked_at is None:
return token
return None
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
self.rename_calls += 1
token = self.get_service_token_by_name(name)
if token is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
if self.get_service_token_by_name(new_name) is not None:
raise psycopg.errors.UniqueViolation("duplicate service-token name")
token.name = new_name
return token
def create_auth_token(
self,
user_id: str,
@@ -231,6 +243,84 @@ def test_revoke_service_token_not_found_raises() -> None:
raise AssertionError("Expected ValueError for missing service token")
def test_rename_service_token_changes_only_name() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
issued = _service(repo).issue_service_token("old-name", "alice")
before = issued.auth_token
protected = (
before.id,
before.user_id,
before.token_prefix,
before.permission,
before.token_type,
before.expires_at,
before.last_used_at,
before.revoked_at,
before.created_at,
before.runtime_id,
before.daemon_id,
)
result = _service(repo).rename_service_token("old-name", "new-name")
assert result.changed is True
assert result.auth_token.name == "new-name"
assert repo.get_service_token_by_name("old-name") is None
assert repo.get_service_token_by_name("new-name") is result.auth_token
assert repo.rename_calls == 1
assert protected == (
result.auth_token.id,
result.auth_token.user_id,
result.auth_token.token_prefix,
result.auth_token.permission,
result.auth_token.token_type,
result.auth_token.expires_at,
result.auth_token.last_used_at,
result.auth_token.revoked_at,
result.auth_token.created_at,
result.auth_token.runtime_id,
result.auth_token.daemon_id,
)
def test_rename_service_token_same_name_is_noop_without_write() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
issued = _service(repo).issue_service_token("unchanged", "alice")
result = _service(repo).rename_service_token("unchanged", "unchanged")
assert result.changed is False
assert result.auth_token is issued.auth_token
assert repo.rename_calls == 0
def test_rename_service_token_missing_and_duplicate_errors() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
service = _service(repo)
service.issue_service_token("source", "alice")
service.issue_service_token("target", "alice")
for old_name, new_name, message in (
("missing", "new", "서비스 토큰을 찾을 수 없습니다: missing"),
("source", "target", "이미 존재하는 서비스 토큰 이름입니다: target"),
):
try:
service.rename_service_token(old_name, new_name)
except ValueError as exc:
assert str(exc) == message
else:
raise AssertionError("Expected rename failure")
# REQ-018: daemon_id forward 보정 (service layer)
def test_issue_service_token_with_daemon_id_stores_it() -> None:
+168 -2
View File
@@ -4,13 +4,25 @@ import json
import uuid
from datetime import datetime, timezone
import app.cli.commands.health as health_commands
from click import unstyle
from typer.testing import CliRunner
import app.cli.commands.health as health_commands
from app.cli.main import create_app
from app.core.config import Settings
from app.core.security import hash_token, token_prefix
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
from app.domain.models import (
Asset,
AuthToken,
BrokerCredential,
Daemon,
LedgerEntry,
Portfolio,
PortfolioComment,
PortfolioTag,
StrategySet,
User,
)
class FakeRepo:
@@ -126,6 +138,15 @@ class FakeRepo:
None,
)
def rename_service_token(self, name: str, new_name: str) -> AuthToken:
token = self.get_service_token_by_name(name)
if token is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
if self.get_service_token_by_name(new_name) is not None:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {new_name}")
token.name = new_name
return token
def create_daemon(
self,
owner_user_id: str,
@@ -1055,6 +1076,7 @@ def test_cli_help_exposes_bot_token_commands() -> None:
assert result.exit_code == 0
assert "issue" in result.stdout
assert "rename" in result.stdout
assert "rebind" in result.stdout
assert "revoke" in result.stdout
@@ -1258,6 +1280,150 @@ def test_cli_service_token_revoke_can_abort() -> None:
assert repo._auth_tokens[token.id].revoked_at is None
def test_cli_service_token_rename_can_abort_without_mutation() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-name",
token_hash=hash_token("rename-token"),
token_prefix=token_prefix("rename-token"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["service-token", "rename", "old-name", "--new-name", "new-name"],
input="n\n",
)
assert result.exit_code == 0
assert repo._auth_tokens[token.id].name == "old-name"
def test_cli_service_token_rename_force_json_has_only_safe_fields() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-name",
token_hash=hash_token("rename-token"),
token_prefix=token_prefix("rename-token"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "rename", "old-name", "--new-name", "new-name", "--force"],
)
assert result.exit_code == 0
assert json.loads(result.stdout) == {
"token_id": token.id,
"old_name": "old-name",
"new_name": "new-name",
"changed": True,
}
assert repo._auth_tokens[token.id].name == "new-name"
def test_cli_service_token_rename_prompt_accepts_and_table_is_secret_free() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-name",
token_hash=hash_token("rename-secret-value"),
token_prefix=token_prefix("rename-secret-value"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["service-token", "rename", "old-name", "--new-name", "new-name"],
input="y\n",
)
assert result.exit_code == 0
for field in ("token_id", "old_name", "new_name", "changed"):
assert field in result.stdout
assert "rename-secret-value" not in result.stdout
assert token.token_prefix not in result.stdout
assert repo._auth_tokens[token.id].name == "new-name"
def test_cli_service_token_rename_requires_new_name_and_reports_domain_errors() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
repo.create_auth_token(
user.id,
name="source",
token_hash=hash_token("rename-source"),
token_prefix=token_prefix("rename-source"),
permission="read",
token_type="service",
)
repo.create_auth_token(
user.id,
name="target",
token_hash=hash_token("rename-target"),
token_prefix=token_prefix("rename-target"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
missing_option = runner.invoke(runner.app, ["service-token", "rename", "source", "--force"])
missing_token = runner.invoke(
runner.app,
["service-token", "rename", "missing", "--new-name", "new", "--force"],
)
duplicate = runner.invoke(
runner.app,
["service-token", "rename", "source", "--new-name", "target", "--force"],
)
assert missing_option.exit_code == 2
assert "--new-name" in unstyle(missing_option.stderr)
assert missing_token.exit_code == 1
assert "서비스 토큰을 찾을 수 없습니다: missing" in missing_token.stdout
assert duplicate.exit_code == 1
assert "이미 존재하는 서비스 토큰 이름입니다: target" in duplicate.stdout
def test_cli_service_token_rename_same_name_short_force_alias() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="same-name",
token_hash=hash_token("rename-token"),
token_prefix=token_prefix("rename-token"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "rename", "same-name", "--new-name", "same-name", "-f"],
)
assert result.exit_code == 0
assert json.loads(result.stdout) == {
"token_id": token.id,
"old_name": "same-name",
"new_name": "same-name",
"changed": False,
}
def test_cli_service_token_revoke_force_succeeds() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
import os
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from urllib.parse import unquote, urlparse
from uuid import uuid4
import psycopg
import pytest
from psycopg import sql
from app.core.config import Settings
from app.core.security import hash_token
from app.db.migrations import upgrade_to_head
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
from app.services.auth_service import AuthService
_LOCAL_DSN_ENV = "PORTFOLIOHUB_TEST_POSTGRES_DSN"
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
def _maintenance_dsn() -> str:
dsn = os.environ.get(_LOCAL_DSN_ENV)
if not dsn:
pytest.skip(
f"{_LOCAL_DSN_ENV} is required for real PostgreSQL integration tests"
)
parsed = urlparse(dsn)
if parsed.scheme not in {"postgres", "postgresql"}:
pytest.fail("test DSN must use postgres/postgresql")
if parsed.hostname not in _LOOPBACK_HOSTS or parsed.path.lstrip("/") != "postgres":
pytest.fail(
"tests require a loopback PostgreSQL maintenance database named postgres"
)
return dsn
def _settings(dsn: str, database: str) -> Settings:
parsed = urlparse(dsn)
assert parsed.hostname is not None
return Settings(
db_enabled=True,
timescaledb_host=parsed.hostname,
timescaledb_port=parsed.port or 5432,
timescaledb_name=database,
timescaledb_user=unquote(parsed.username or ""),
timescaledb_password=unquote(parsed.password or ""),
timescaledb_sslmode="disable",
timescaledb_connect_timeout_sec=3,
timescaledb_application_name="service-token-rename-test",
)
def _repo(settings: Settings) -> PostgresPortfolioRepository:
return PostgresPortfolioRepository(
PortfolioDBConfig(
host=settings.active_db_host,
port=settings.active_db_port,
dbname=settings.active_db_name,
user=settings.active_db_user,
password=settings.active_db_password,
sslmode=settings.active_db_sslmode,
connect_timeout_sec=settings.active_db_connect_timeout_sec,
application_name=settings.active_db_application_name,
)
)
@pytest.fixture()
def postgres_context() -> Iterator[tuple[Settings, PostgresPortfolioRepository]]:
maintenance_dsn = _maintenance_dsn()
database = f"portfolio_hub_token_rename_{uuid4().hex}"
with (
psycopg.connect(maintenance_dsn, autocommit=True) as conn,
conn.cursor() as cur,
):
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database)))
settings = _settings(maintenance_dsn, database)
upgrade_to_head(settings)
repo = _repo(settings)
repo.create_user("alice", permission="read")
try:
yield settings, repo
finally:
repo.close()
with (
psycopg.connect(maintenance_dsn, autocommit=True) as conn,
conn.cursor() as cur,
):
cur.execute(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE datname = %s AND pid <> pg_backend_pid()",
(database,),
)
cur.execute(
sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(database))
)
def _stored_row(repo: PostgresPortfolioRepository, token_id: str) -> tuple:
with repo._connect().cursor() as cur:
cur.execute(
"""
SELECT id, user_id, name, token_prefix, token_hash, permission, token_type,
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
FROM auth_tokens
WHERE id = %s
""",
(token_id,),
)
row = cur.fetchone()
assert row is not None
return row
def test_rename_persists_only_name_and_preserves_bearer_authentication(
postgres_context,
) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
issued = service.issue_service_token("old-name", "alice")
before = _stored_row(repo, issued.auth_token.id)
renamed = service.rename_service_token("old-name", "new-name")
after = _stored_row(repo, issued.auth_token.id)
assert renamed.changed is True
assert renamed.auth_token.id == issued.auth_token.id
assert after[2] == "new-name"
assert before[:2] + before[3:] == after[:2] + after[3:]
assert repo.get_service_token_by_name("old-name") is None
assert repo.get_service_token_by_name("new-name").id == issued.auth_token.id
principal = repo.get_token_principal(hash_token(issued.token))
assert principal is not None
assert principal.user_id == issued.auth_token.user_id
assert principal.permission == issued.auth_token.permission
assert principal.runtime_id == issued.auth_token.runtime_id
assert principal.daemon_id == issued.auth_token.daemon_id
def test_rename_collision_rolls_back_and_revoked_name_is_reusable(
postgres_context,
) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
source = service.issue_service_token("source", "alice")
target = service.issue_service_token("target", "alice")
source_before = _stored_row(repo, source.auth_token.id)
target_before = _stored_row(repo, target.auth_token.id)
with pytest.raises(
ValueError, match="이미 존재하는 서비스 토큰 이름입니다: target"
):
service.rename_service_token("source", "target")
assert _stored_row(repo, source.auth_token.id) == source_before
assert _stored_row(repo, target.auth_token.id) == target_before
repo.revoke_auth_token(target.auth_token.user_id, target.auth_token.id)
renamed = service.rename_service_token("source", "target")
assert renamed.changed is True
assert renamed.auth_token.id == source.auth_token.id
def test_same_name_is_noop_and_missing_same_name_fails(postgres_context) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
issued = service.issue_service_token("same-name", "alice")
before = _stored_row(repo, issued.auth_token.id)
result = service.rename_service_token("same-name", "same-name")
assert result.changed is False
assert _stored_row(repo, issued.auth_token.id) == before
with pytest.raises(ValueError, match="서비스 토큰을 찾을 수 없습니다: missing"):
service.rename_service_token("missing", "missing")
revoked = service.issue_service_token("revoked-source", "alice")
repo.revoke_auth_token(revoked.auth_token.user_id, revoked.auth_token.id)
revoked_before = _stored_row(repo, revoked.auth_token.id)
with pytest.raises(
ValueError, match="서비스 토큰을 찾을 수 없습니다: revoked-source"
):
service.rename_service_token("revoked-source", "new-name")
assert _stored_row(repo, revoked.auth_token.id) == revoked_before
def test_concurrent_renames_to_same_target_have_one_winner(postgres_context) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
first = service.issue_service_token("race-first", "alice")
second = service.issue_service_token("race-second", "alice")
barrier = Barrier(2)
def worker(source: str) -> str:
worker_repo = _repo(settings)
try:
barrier.wait()
AuthService(worker_repo, settings).rename_service_token(
source, "race-target"
)
return source
except ValueError as exc:
return str(exc)
finally:
worker_repo.close()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(worker, "race-first"),
executor.submit(worker, "race-second"),
]
results = [future.result(timeout=15) for future in futures]
winners = [result for result in results if result in {"race-first", "race-second"}]
conflicts = [
result
for result in results
if result == "이미 존재하는 서비스 토큰 이름입니다: race-target"
]
assert len(winners) == 1
assert len(conflicts) == 1
active_target = repo.get_service_token_by_name("race-target")
assert active_target is not None
expected_id = (
first.auth_token.id if winners == ["race-first"] else second.auth_token.id
)
assert active_target.id == expected_id
def test_concurrent_rename_and_issue_have_one_winner(postgres_context) -> None:
settings, repo = postgres_context
service = AuthService(repo, settings)
source = service.issue_service_token("race-source", "alice")
barrier = Barrier(2)
def rename_worker() -> str:
worker_repo = _repo(settings)
try:
barrier.wait()
AuthService(worker_repo, settings).rename_service_token(
"race-source", "race-target"
)
return "renamed"
except ValueError as exc:
return str(exc)
finally:
worker_repo.close()
def issue_worker() -> str:
worker_repo = _repo(settings)
try:
barrier.wait()
AuthService(worker_repo, settings).issue_service_token(
"race-target", "alice"
)
return "issued"
except ValueError as exc:
return str(exc)
finally:
worker_repo.close()
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = [executor.submit(rename_worker), executor.submit(issue_worker)]
results = [future.result(timeout=15) for future in outcomes]
winners = [result for result in results if result in {"renamed", "issued"}]
conflicts = [
result
for result in results
if result == "이미 존재하는 서비스 토큰 이름입니다: race-target"
]
assert len(winners) == 1
assert len(conflicts) == 1
active_target = repo.get_service_token_by_name("race-target")
assert active_target is not None
if winners == ["renamed"]:
assert active_target.id == source.auth_token.id
assert repo.get_service_token_by_name("race-source") is None
else:
assert active_target.id != source.auth_token.id
assert repo.get_service_token_by_name("race-source").id == source.auth_token.id