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:
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user