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
+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")