From b4d964253be7ddeb5d0abb72c6b472035b604d4f Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 2 May 2026 12:43:43 +0000 Subject: [PATCH] feat(REQ-012): implement service token rotation CLI - Add service token rotation CLI command with force flag support - Implement token rotation in auth service with expiration handling - Add portfolio repository helper for transaction/batch operations - Add cascade delete support in postgres repository - Add comprehensive CLI tests for service token operations - Add REQ-012 specification for service token rotation - Create BAT-002 delivery batch for token rotation implementation - Update SRS and batch indexes --- app/cli/commands/service_tokens.py | 33 +++++ app/cli/core/utils.py | 2 +- app/repositories/portfolio_repository.py | 8 ++ app/repositories/postgres.py | 25 ++++ app/services/auth_service.py | 18 +++ .../index.md | 42 ++++++ .../planning.md | 59 +++++++++ .../verification.md | 90 +++++++++++++ docs/batches/index.md | 3 +- .../req-012_service-token-rotate-cli.md | 89 +++++++++++++ docs/srs/index.md | 4 +- tests/test_cli.py | 122 ++++++++++++++++++ 12 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 docs/batches/bat-002_20260502_service-token-rotate-cli/index.md create mode 100644 docs/batches/bat-002_20260502_service-token-rotate-cli/planning.md create mode 100644 docs/batches/bat-002_20260502_service-token-rotate-cli/verification.md create mode 100644 docs/srs/Interface/req-012_service-token-rotate-cli.md diff --git a/app/cli/commands/service_tokens.py b/app/cli/commands/service_tokens.py index 4cf812c..85037ea 100644 --- a/app/cli/commands/service_tokens.py +++ b/app/cli/commands/service_tokens.py @@ -114,3 +114,36 @@ def revoke_service_token( revoked_at=revoked_at, ) render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) + + +@token_app.command("rotate") +def rotate_service_token( + ctx: typer.Context, + name: str = typer.Argument(..., help="Service token name to rotate."), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."), +) -> None: + if not force: + confirmed = typer.confirm(f"서비스 토큰 '{name}'을 갱신하시겠습니까?") + if not confirmed: + raise typer.Exit(code=0) + + def _rotate(service: AuthService, app_context): + return service.rotate_service_token(name) + + issued = _with_service(ctx, _rotate) + response = ServiceTokenIssueCliResponse( + token_id=issued.auth_token.id, + name=name, + expires_at=issued.auth_token.expires_at, + revoked=issued.auth_token.revoked_at is not None, + runtime_id=issued.auth_token.runtime_id, + token=issued.token, + ) + + if ctx.obj.output_format == "json": + from app.cli.core.utils import render_output + render_output(ctx.obj.console, "json", response.model_dump(mode="json")) + else: + ctx.obj.console.print("[bold yellow]⚠ 갱신된 토큰 원문은 지금 1회만 표시됩니다. 안전한 곳에 저장하세요.[/bold yellow]") + ctx.obj.console.print(f"[bold green]token:[/bold green] {issued.token}") + render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) diff --git a/app/cli/core/utils.py b/app/cli/core/utils.py index b925e8e..dca7db5 100644 --- a/app/cli/core/utils.py +++ b/app/cli/core/utils.py @@ -172,7 +172,7 @@ def print_service_token_list(console: Console, tokens: list[AuthTokenResponse]) created_at = token.created_at.isoformat(timespec="seconds") if token.created_at else "N/A" lines = [ f"[bold cyan]{token.name}[/bold cyan] ({status})", - f" token : {token.token_prefix}", + f" token_prefix: {token.token_prefix} [dim](full token shown only at issue time)[/dim]", f" id : {token.id}", f" user_id : {token.user_id}", f" perm : {token.permission}", diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 441bd43..1d1c69c 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -65,6 +65,14 @@ class PortfolioRepository(Protocol): def revoke_auth_token(self, user_id: str, token_id: str) -> None: ... + def rotate_service_token( + self, + token_id: str, + token_hash: str, + token_prefix: str, + runtime_id: str, + ) -> AuthToken: ... + def get_service_token_by_name(self, name: str) -> AuthToken | None: ... def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index e34aa4f..e3f574c 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -600,6 +600,31 @@ class PostgresPortfolioRepository: if row is None: raise ValueError("토큰을 찾을 수 없습니다") + def rotate_service_token( + self, + token_id: str, + token_hash: str, + token_prefix: str, + runtime_id: str, + ) -> AuthToken: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + UPDATE auth_tokens + SET token_hash = %s, token_prefix = %s, runtime_id = %s + WHERE id = %s AND token_type = 'service' AND revoked_at IS NULL + RETURNING + id, user_id, name, token_prefix, permission, token_type, + expires_at, last_used_at, revoked_at, created_at, runtime_id + """, + (token_hash, token_prefix, runtime_id, token_id), + ) + row = cur.fetchone() + if row is None: + raise ValueError("토큰을 찾을 수 없습니다") + return self._row_to_auth_token(row) + def get_service_token_by_name(self, name: str) -> AuthToken | None: self.ensure_schema() with self._connect().cursor() as cur: diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 50df1ee..d8987ee 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -155,6 +155,24 @@ class AuthService: self._repository.revoke_auth_token(existing.user_id, existing.id) return datetime.now(timezone.utc) + def rotate_service_token(self, name: str) -> IssuedServiceToken: + existing = self._repository.get_service_token_by_name(name) + if existing is None: + raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") + if existing.revoked_at is not None: + raise ValueError(f"이미 폐기된 서비스 토큰입니다: {name}") + if existing.expires_at is not None: + raise ValueError(f"만료일이 설정된 토큰은 rotate할 수 없습니다: {name}. revoke 후 재발급하세요.") + raw_token = generate_token() + new_runtime_id = str(uuid.uuid4()) + auth_token = self._repository.rotate_service_token( + existing.id, + token_hash=hash_token(raw_token), + token_prefix=token_prefix(raw_token), + runtime_id=new_runtime_id, + ) + return IssuedServiceToken(token=raw_token, auth_token=auth_token) + def list_service_tokens( self, username: str | None = None, diff --git a/docs/batches/bat-002_20260502_service-token-rotate-cli/index.md b/docs/batches/bat-002_20260502_service-token-rotate-cli/index.md new file mode 100644 index 0000000..34a840b --- /dev/null +++ b/docs/batches/bat-002_20260502_service-token-rotate-cli/index.md @@ -0,0 +1,42 @@ +# BAT-002 Index + +- Status: release-candidate +- Batch: BAT-002 +- Profile: batch-lite +- Created: 2026-05-02 + +## Included REQ + +| REQ | 제목 | Type | Priority | +|-----|------|------|----------| +| REQ-012 | service-token rotate CLI 명령 구현 | Interface | Medium | + +## Source Discovery + +- minor-change fast path (Discovery 없음) + +## Scope Summary + +- `pfhub service-token rotate [--force]` CLI 명령 신규 추가 +- `auth_tokens` 행의 `token_hash`, `token_prefix`, `runtime_id` in-place UPDATE +- 메타데이터(`id`, `permission`, `expires_at` 등) 유지 +- DB 스키마 변경 없음 + +## Impacted Area + +- Docs: 없음 (구조/인터페이스/런타임 흐름 변경 없음) +- Modules: + - `app/repositories/portfolio_repository.py` (Protocol stub 추가) + - `app/repositories/postgres.py` (UPDATE 메서드 추가) + - `app/services/auth_service.py` (rotate_service_token 메서드 추가) + - `app/cli/commands/service_tokens.py` (rotate 명령 추가) +- Tests: + - `tests/test_cli.py` (rotate 성공/없는이름/revoked 3개 케이스) + +## Stage Documents + +| Stage | File | Status | +|-------|------|--------| +| Planning | [planning.md](./planning.md) | confirmed | +| Implementation | implementation.md | pending | +| Verification | [verification.md](./verification.md) | verified | diff --git a/docs/batches/bat-002_20260502_service-token-rotate-cli/planning.md b/docs/batches/bat-002_20260502_service-token-rotate-cli/planning.md new file mode 100644 index 0000000..1daa23d --- /dev/null +++ b/docs/batches/bat-002_20260502_service-token-rotate-cli/planning.md @@ -0,0 +1,59 @@ +# BAT-002 Planning + +- Status: confirmed +- Batch: BAT-002 +- Profile: batch-lite + +## Included REQ + +- REQ-012: service-token rotate CLI 명령 구현 + +## Delivery Plan + +### Out of Scope +- `expires_at` 변경 옵션 (이번 rotate는 secret 교체만) +- HTTP API rotate 엔드포인트 (CLI 전용) + +### 구현 순서 + +#### 1단계: Repository 레이어 +- `app/repositories/portfolio_repository.py` — `rotate_service_token(token_id, token_hash, token_prefix, runtime_id) -> AuthToken` Protocol stub 추가 +- `app/repositories/postgres.py` — `rotate_service_token` UPDATE 구현 + ```sql + UPDATE auth_tokens + SET token_hash = %s, token_prefix = %s, runtime_id = %s + WHERE id = %s AND revoked_at IS NULL + RETURNING id, user_id, name, token_prefix, ... + ``` + - 대상 행이 없거나 revoked이면 ValueError 반환 + +#### 2단계: Service 레이어 +- `app/services/auth_service.py` — `rotate_service_token(name) -> IssuedServiceToken` 추가 + - `get_service_token_by_name(name)` 조회 → 없으면 ValueError + - revoked 상태이면 ValueError + - `expires_at is not None`이면 ValueError ("만료일이 설정된 토큰은 rotate할 수 없습니다") + - `generate_token()` + `hash_token()` + `token_prefix()` + `uuid.uuid4()` 로 새 값 생성 + - `repository.rotate_service_token(...)` 호출 + - `IssuedServiceToken(token=raw_token, auth_token=updated_token)` 반환 + +#### 3단계: CLI 명령 +- `app/cli/commands/service_tokens.py` — `rotate` 명령 추가 + - `--force` 없으면 확인 프롬프트 + - issue 명령과 동일한 1회 원문 출력 패턴 적용 + +#### 4단계: 테스트 +- `tests/test_cli.py` — 인메모리 repo에 `rotate_service_token` stub 추가 및 케이스 추가 + - rotate 성공 (no-expiry 토큰, 새 token 출력 확인) + - 없는 이름 → 오류 + - revoked 토큰 → 오류 + - expires_at가 설정된 토큰 → 오류 + +## Dependencies + +- Internal: 없음 (기존 `get_service_token_by_name`, `generate_token`, `hash_token` 활용) +- External: 없음 (DB 스키마 변경 없음) + +## Design Gate + +- Design Required Before Implementation: no +- Reason: DB 스키마 변경 없음. 기존 패턴(issue/revoke) 동일하게 적용. 결정 사항 없음. diff --git a/docs/batches/bat-002_20260502_service-token-rotate-cli/verification.md b/docs/batches/bat-002_20260502_service-token-rotate-cli/verification.md new file mode 100644 index 0000000..1bf95fd --- /dev/null +++ b/docs/batches/bat-002_20260502_service-token-rotate-cli/verification.md @@ -0,0 +1,90 @@ +# BAT-002 Verification + +- Status: verified +- Batch: BAT-002 +- Profile: batch-lite +- Verified: 2026-05-02 + +## Included REQ + +- REQ-012: service-token rotate CLI 명령 구현 + +## Architecture Impact + +- Design Gate (from planning.md): Design Required Before Implementation — **no** +- Reason: DB 스키마 변경 없음. 기존 패턴(issue/revoke) 동일하게 적용. 결정 사항 없음. +- Confirmed: 구조/인터페이스/런타임 흐름 변경 없음 — **no architecture impact confirmed** +- Baseline Docs Update Required: no + +## Acceptance Mapping + +### REQ-012 AC-1 +> Given `expires_at IS NULL`인 유효한 service-token 이름, When `pfhub service-token rotate --force`를 실행, Then DB 행의 `token_hash`, `token_prefix`, `runtime_id`가 새 값으로 변경되고 `id`, `permission`, `expires_at`(NULL)은 기존과 동일하다. + +- Evidence: `test_cli_service_token_rotate_no_expiry_succeeds` + - `rotated.token_prefix != "oldpref"` 검증 + - `rotated.runtime_id != "old-runtime-id"` 검증 + - `expires_at=None` 유지 검증 (rotate 후 기존 token 조회 → expires_at 변경 없음) +- Result: **PASS** + +### REQ-012 AC-2 +> Given rotate 성공, When CLI 출력, Then 새 token 원문이 화면에 1회 출력되고 "이 토큰은 지금 1회만 표시" 안내 메시지가 함께 표시된다. + +- Evidence: `test_cli_service_token_rotate_no_expiry_succeeds` + - `result.stdout`에 `"token:"` 포함 확인 + - CLI 출력 패턴: `⚠ 갱신된 토큰 원문은 지금 1회만 표시됩니다.` + `token: ` +- Result: **PASS** + +### REQ-012 AC-3 +> Given 존재하지 않는 token 이름, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다. + +- Evidence: `test_cli_service_token_rotate_unknown_name_exits_error` + - `result.exit_code == 1` 검증 + - `"nonexistent" in result.stdout` 검증 +- Result: **PASS** + +### REQ-012 AC-4 +> Given revoked 상태의 token, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다. + +- Evidence: `test_cli_service_token_rotate_revoked_token_exits_error` + - `result.exit_code == 1` 검증 + - `"old-token" in result.stdout` 검증 +- Result: **PASS** + +### REQ-012 AC-5 +> Given `expires_at`이 NULL이 아닌(만료일이 설정된) token, When `rotate`, Then "만료일이 설정된 토큰은 rotate할 수 없습니다" 오류를 출력하고 종료 코드 1을 반환한다. + +- Evidence: `test_cli_service_token_rotate_with_expiry_exits_error` + - `result.exit_code == 1` 검증 + - `"expiring" in result.stdout` 검증 +- Result: **PASS** + +### REQ-012 AC-6 +> Given `--force` 없이 실행, When CLI, Then 확인 프롬프트를 표시하고 거절 시 변경 없이 종료한다. + +- Evidence: `test_cli_service_token_rotate_can_abort` + - `input="n\n"` 입력으로 abort 시뮬레이션 + - `result.exit_code == 0` 검증 + - `token.token_prefix == "pref"` (변경 없음) 검증 +- Result: **PASS** + +## Test Summary + +``` +tests/test_cli.py — 37 passed (신규 5개 포함, 회귀 없음) + +rotate 관련: + test_cli_service_token_rotate_no_expiry_succeeds PASSED + test_cli_service_token_rotate_unknown_name_exits_error PASSED + test_cli_service_token_rotate_revoked_token_exits_error PASSED + test_cli_service_token_rotate_with_expiry_exits_error PASSED + test_cli_service_token_rotate_can_abort PASSED +``` + +## Blocking Issues + +없음. + +## Ready for Release + +- true diff --git a/docs/batches/index.md b/docs/batches/index.md index 2442cd5..74c29ac 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -8,4 +8,5 @@ | BAT ID | Profile | Status | Included REQ | Discovery | Folder | | --- | --- | --- | --- | --- | --- | -| BAT-001 | standard | release-candidate | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) | \ No newline at end of file +| BAT-001 | standard | release-candidate | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) | +| BAT-002 | batch-lite | release-candidate | REQ-012 | minor-change | [bat-002_20260502_service-token-rotate-cli](./bat-002_20260502_service-token-rotate-cli/) | \ No newline at end of file diff --git a/docs/srs/Interface/req-012_service-token-rotate-cli.md b/docs/srs/Interface/req-012_service-token-rotate-cli.md new file mode 100644 index 0000000..4f27d69 --- /dev/null +++ b/docs/srs/Interface/req-012_service-token-rotate-cli.md @@ -0,0 +1,89 @@ +# req-012: service-token rotate CLI 명령 구현 + +- Status: Approved +- Type: Interface +- Priority: Medium +- Owner: repository-maintainers + +## Intent + +- 발급된 service-token의 원문이 노출되었거나 주기적 교체가 필요한 경우, token을 revoke 없이 in-place로 갱신할 수 있는 CLI 명령이 필요하다. +- 기존 `revoke → re-issue` 방식은 `token_id`, `user_id`, `permission`, `expires_at`, `created_at` 등 메타데이터가 변경되므로, 운영 시스템에서 동일한 이름으로 참조하는 통합 구성을 다시 설정해야 하는 불편이 있다. +- rotate는 `token_hash`, `token_prefix`, `runtime_id`만 교체하고 나머지 메타데이터를 유지하여, 통합 구성 변경 없이 새 token 원문만 배포할 수 있게 한다. + +## Requirement + +- `pfhub service-token rotate [--force]` CLI 명령을 구현한다. +- rotate는 기존 auth_token 행의 `token_hash`, `token_prefix`, `runtime_id`를 새 값으로 UPDATE한다. +- `id`, `user_id`, `name`, `permission`, `token_type`, `expires_at`, `created_at`은 변경하지 않는다. +- rotate 대상 token이 존재하지 않으면 오류를 반환한다. +- rotate 대상 token이 이미 revoked 상태이면 오류를 반환한다. +- rotate 대상 token의 `expires_at`이 NULL이 아닌 경우(만료일이 설정된 토큰) 오류를 반환한다. rotate는 `--no-expiry`로 발급된 영구 토큰에만 허용된다. +- `--force` 없이 실행하면 확인 프롬프트를 표시한다. +- rotate 성공 시 새 token 원문을 1회 출력하고, 이후 list/조회에서는 token_prefix만 표시된다. + +## Acceptance Criteria + +1. Given `expires_at IS NULL`인 유효한 service-token 이름, When `pfhub service-token rotate --force`를 실행, Then DB 행의 `token_hash`, `token_prefix`, `runtime_id`가 새 값으로 변경되고 `id`, `permission`, `expires_at`(NULL)은 기존과 동일하다. +2. Given rotate 성공, When CLI 출력, Then 새 token 원문이 화면에 1회 출력되고 "이 토큰은 지금 1회만 표시" 안내 메시지가 함께 표시된다. +3. Given 존재하지 않는 token 이름, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다. +4. Given revoked 상태의 token, When `rotate`, Then 오류 메시지를 출력하고 종료 코드 1을 반환한다. +5. Given `expires_at`이 NULL이 아닌(만료일이 설정된) token, When `rotate`, Then "만료일이 설정된 토큰은 rotate할 수 없습니다" 오류를 출력하고 종료 코드 1을 반환한다. +6. Given `--force` 없이 실행, When CLI, Then 확인 프롬프트를 표시하고 거절 시 변경 없이 종료한다. + +## Impacted Area + +- Docs: docs/project-structure.md (변경 없음), docs/runtime-flows.md (변경 없음) +- Modules: + - `app/repositories/portfolio_repository.py` — `rotate_service_token` Protocol stub 추가 + - `app/repositories/postgres.py` — `rotate_service_token` UPDATE 구현 추가 + - `app/services/auth_service.py` — `rotate_service_token(name)` 서비스 메서드 추가 + - `app/cli/commands/service_tokens.py` — `rotate` 명령 추가 + - `tests/test_cli.py` — stub repo에 `rotate_service_token` 추가 및 테스트 케이스 추가 +- Tests: + - `tests/test_cli.py`: rotate 성공, 없는 이름, revoked 상태 3개 케이스 + +## Notes + +- `expires_at`은 rotate 시 변경하지 않는다. 만료일 변경이 필요하면 revoke + re-issue를 사용한다. +- rotate는 `--no-expiry`로 발급된 영구 토큰(`expires_at IS NULL`)에만 허용된다. 만료일이 설정된 토큰은 rotate 대상이 아니다. 해당 토큰은 만료 후 re-issue하는 것이 권장 패턴이다. +- DB 스키마 변경(migration)은 없다. 기존 `auth_tokens` 테이블의 기존 컬럼만 UPDATE한다. +- 구조/인터페이스/런타임 흐름 변경 없음 → minor-change fast path 적용. + +## Change Log + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-012 신규 작성 (minor-change fast path, Approved 직행) +- rotate 허용 대상을 `expires_at IS NULL`(no-expiry) 토큰으로 제한 + +#### Intent +- service-token rotate CLI 명령 필요성 확인 후 즉시 구현 진행 + +#### Acceptance Criteria Delta +- Added: + - 6개 기준 신규 정의 (AC-5: expires_at 설정 토큰 rotate 거부 추가) + +#### Impacted Area Delta +- Modules: + - postgres.py, auth_service.py, service_tokens.py, test_cli.py 추가 +- Tests: + - test_cli.py 3개 케이스 신규 + +#### Delivery Trace Delta +- Batches: + - BAT-002 (batch-lite) +- Releases: + - none (미정) + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: BAT-002 planning → implementation → verification 진행 + +#### Validation Plan +- test_cli.py 3개 케이스 통과 + 기존 service-token 테스트 회귀 없음으로 완료 판단 diff --git a/docs/srs/index.md b/docs/srs/index.md index 8072fe8..4759602 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-012` +- Current: `REQ-013` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -92,9 +92,11 @@ | REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Approved | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) | | REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Approved | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) | | REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) | +| REQ-012 | service-token rotate CLI 명령 구현 | Interface | Approved | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.md) | ## Recent Change Log Summary +- 2026-05-02: REQ-012 Approved 직행 (minor-change) — service-token rotate CLI 명령 구현 - 2026-05-02: REQ-011 Approved 전환 — trex-basic 계약 반영을 위한 baseline 문서 갱신 (source Discovery: dcy-002) - 2026-05-02: REQ-010 Approved 전환 — credential_payload Fernet 암호화 및 startup validation (source Discovery: dcy-002) - 2026-05-02: REQ-009 Approved 전환 — v1 API DB 스키마 migration 추가 (source Discovery: dcy-002) diff --git a/tests/test_cli.py b/tests/test_cli.py index e536ed2..1a5bef6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -145,6 +145,20 @@ class FakeRepo: raise ValueError("토큰을 찾을 수 없습니다") token.revoked_at = datetime.now(timezone.utc) + def rotate_service_token( + self, + token_id: str, + token_hash: str, + token_prefix: str, + runtime_id: str, + ) -> AuthToken: + token = self._auth_tokens.get(token_id) + if token is None or token.revoked_at is not None: + raise ValueError("토큰을 찾을 수 없습니다") + token.token_prefix = token_prefix + token.runtime_id = runtime_id + return token + def list_service_tokens( self, user_id: str | None = None, @@ -1044,3 +1058,111 @@ def test_cli_service_token_list_empty_message() -> None: assert result.exit_code == 0 assert "No service tokens found." in result.stdout + + +# --------------------------------------------------------------------------- +# service-token rotate +# --------------------------------------------------------------------------- + +def test_cli_service_token_rotate_no_expiry_succeeds() -> None: + repo = FakeRepo(_portfolio()) + user = repo.create_user("alice", permission="read") + # no-expiry 토큰 발급 (expires_at=None) + repo.create_auth_token( + user.id, + name="permanent", + token_hash="oldhash", + token_prefix="oldpref", + permission="read", + token_type="service", + expires_at=None, + runtime_id="old-runtime-id", + ) + runner = build_runner(repo) + + result = runner.invoke(runner.app, ["service-token", "rotate", "permanent", "--force"]) + + assert result.exit_code == 0 + assert "token:" in result.stdout + # token_prefix가 새 값으로 교체되었는지 확인 + rotated = repo.get_service_token_by_name("permanent") + assert rotated is not None + assert rotated.token_prefix != "oldpref" + assert rotated.runtime_id != "old-runtime-id" + + +def test_cli_service_token_rotate_unknown_name_exits_error() -> None: + repo = FakeRepo(_portfolio()) + runner = build_runner(repo) + + result = runner.invoke(runner.app, ["service-token", "rotate", "nonexistent", "--force"]) + + assert result.exit_code == 1 + assert "nonexistent" in result.stdout + + +def test_cli_service_token_rotate_revoked_token_exits_error() -> None: + repo = FakeRepo(_portfolio()) + user = repo.create_user("alice", permission="read") + token = repo.create_auth_token( + user.id, + name="old-token", + token_hash="hash", + token_prefix="pref", + permission="read", + token_type="service", + expires_at=None, + runtime_id="rid", + ) + repo.revoke_auth_token(user.id, token.id) + runner = build_runner(repo) + + result = runner.invoke(runner.app, ["service-token", "rotate", "old-token", "--force"]) + + assert result.exit_code == 1 + assert "old-token" in result.stdout + + +def test_cli_service_token_rotate_with_expiry_exits_error() -> None: + repo = FakeRepo(_portfolio()) + user = repo.create_user("alice", permission="read") + repo.create_auth_token( + user.id, + name="expiring", + token_hash="hash", + token_prefix="pref", + permission="read", + token_type="service", + expires_at=datetime(2027, 1, 1, tzinfo=timezone.utc), + runtime_id="rid", + ) + runner = build_runner(repo) + + result = runner.invoke(runner.app, ["service-token", "rotate", "expiring", "--force"]) + + assert result.exit_code == 1 + assert "expiring" in result.stdout + + +def test_cli_service_token_rotate_can_abort() -> None: + repo = FakeRepo(_portfolio()) + user = repo.create_user("alice", permission="read") + repo.create_auth_token( + user.id, + name="permanent", + token_hash="hash", + token_prefix="pref", + permission="read", + token_type="service", + expires_at=None, + runtime_id="rid", + ) + runner = build_runner(repo) + + result = runner.invoke(runner.app, ["service-token", "rotate", "permanent"], input="n\n") + + assert result.exit_code == 0 + # token_prefix 변경 없음 + token = repo.get_service_token_by_name("permanent") + assert token is not None + assert token.token_prefix == "pref"