fix: reveal plaintext service token on issue/rotate (HOTFIX-001)

- render_service_token_result no longer masks the token field; plaintext
  tokens are SHA-256 hashed in DB and irrecoverable once lost, so issue/
  rotate must reveal them once at issuance (matches interface-contract.md)
- Remove now-unused _mask_payload helper; keep mask_token_text for the
  auxiliary masked_token identifier line
- Add one-time reveal warning to issue/rotate table output
- 3 regression tests: JSON plaintext assertion, render unit test, table warning
- Hotfix Exception Path record: docs/hotfixes/hotfix-001
- Total tests: 369 -> 371 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 17:36:05 +09:00
co-authored by Claude Opus 4.7
parent 58899b1a94
commit 3a78e5c138
4 changed files with 123 additions and 11 deletions
+52
View File
@@ -1058,6 +1058,58 @@ def test_cli_service_token_issue_supports_json() -> None:
assert payload["revoked"] is False
assert payload["expires_at"] is not None
assert payload["runtime_id"] is not None
# HOTFIX-001 SC-1: JSON 출력의 token 필드는 마스킹되지 않은 평문이어야 한다.
assert "*" not in payload["token"]
stored = repo.get_service_token_by_name("nightly")
assert payload["token"].startswith(stored.token_prefix)
def test_render_service_token_result_does_not_mask_token() -> None:
"""HOTFIX-001 SC-1/SC-2: render_service_token_result는 token을 평문으로 렌더링한다.
issue/rotate 모두 동일하게 render_service_token_result를 호출하므로 본 단위 테스트가
두 경로(SC-1 issue, SC-2 rotate)의 렌더링 동작을 함께 커버한다.
"""
import io
from rich.console import Console
from app.cli.core.output import render_service_token_result
from app.schemas.auth import ServiceTokenIssueCliResponse
plaintext = "ySusw9hQ2kPlAiNtExTtOkEnVaLuENotMaskedHere_Wa3w"
response = ServiceTokenIssueCliResponse(
token_id="540e005a-104b-451a-b11c-0eb03968b958",
name="admin",
expires_at=None,
revoked=False,
runtime_id="c544ab21-ce00-41cd-8327-5bf9103ebad8",
daemon_id=None,
token=plaintext,
)
for fmt in ("json", "table"):
buffer = io.StringIO()
console = Console(file=buffer, force_terminal=False, width=200)
render_service_token_result(console, fmt, response)
output = buffer.getvalue()
assert plaintext in output, f"{fmt}: 평문 토큰이 출력에 없음"
assert "*" not in output, f"{fmt}: 마스킹 문자가 출력에 포함됨"
def test_cli_service_token_issue_table_shows_reveal_warning() -> None:
"""HOTFIX-001 SC-3: table 출력은 평문 토큰 + 1회 노출 경고를 표기한다."""
repo = FakeRepo(_portfolio())
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["service-token", "issue", "nightly", "--user", "alice"],
)
assert result.exit_code == 0
stored = repo.get_service_token_by_name("nightly")
assert stored.token_prefix in result.stdout
assert "다시 표시되지 않습니다" in result.stdout
def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None: