Files
portfolio-hub/tests/test_service_token_rename_postgres.py
T
genichin deaaa96c46 feat: add service token rename command (#40)
* docs: preserve original issue 5 body

* feat: add service token rename command

* test: normalize rich option errors
2026-09-19 21:19:39 +09:00

286 lines
10 KiB
Python

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