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:
@@ -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