From deaaa96c4693fd36791c3f567b7dc2e4fba8a8b9 Mon Sep 17 00:00:00 2001 From: Jinhwan Ko Date: Sat, 19 Sep 2026 21:19:39 +0900 Subject: [PATCH] feat: add service token rename command (#40) * docs: preserve original issue 5 body * feat: add service token rename command * test: normalize rich option errors --- .github/workflows/verify.yml | 2 +- .proofline/criteria/ac-0042.md | 16 + .proofline/criteria/ac-0043.md | 16 + .proofline/criteria/ac-0044.md | 16 + .proofline/identities.json | 4 +- .proofline/lines/line-0010/dcy-0010.md | 51 ++++ .../lines/line-0010/evidence/activity-log.md | 25 ++ .proofline/lines/line-0010/line-0010.md | 4 + .proofline/lines/line-0010/req-0010.md | 44 +++ README.md | 2 + app/cli/commands/service_tokens.py | 34 ++- app/repositories/portfolio_repository.py | 2 + app/repositories/postgres.py | 31 ++ app/schemas/auth.py | 7 + app/services/auth_service.py | 19 ++ docs/issues/issue-0005-original.md | 68 +++++ tests/release/test_release_contracts.py | 1 + tests/test_auth.py | 92 +++++- tests/test_cli.py | 170 ++++++++++- tests/test_service_token_rename_postgres.py | 285 ++++++++++++++++++ 20 files changed, 882 insertions(+), 7 deletions(-) create mode 100644 .proofline/criteria/ac-0042.md create mode 100644 .proofline/criteria/ac-0043.md create mode 100644 .proofline/criteria/ac-0044.md create mode 100644 .proofline/lines/line-0010/dcy-0010.md create mode 100644 .proofline/lines/line-0010/evidence/activity-log.md create mode 100644 .proofline/lines/line-0010/line-0010.md create mode 100644 .proofline/lines/line-0010/req-0010.md create mode 100644 docs/issues/issue-0005-original.md create mode 100644 tests/test_service_token_rename_postgres.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4a7c82e..5bbfc8b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -42,7 +42,7 @@ jobs: POSTGRES_PASSWORD: change-me run: | export PORTFOLIOHUB_TEST_POSTGRES_DSN="postgresql://portfolio_hub_user:${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres" - uv run python -m pytest -q tests/test_corporate_action_atomic_recovery_postgres.py + uv run python -m pytest -q tests/test_corporate_action_atomic_recovery_postgres.py tests/test_service_token_rename_postgres.py - name: Honest scoped lint for release additions run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py scripts/ruff_ratchet.py tests/release - name: Reject Ruff findings introduced by v0.3.0 or later patches diff --git a/.proofline/criteria/ac-0042.md b/.proofline/criteria/ac-0042.md new file mode 100644 index 0000000..4ba3a3b --- /dev/null +++ b/.proofline/criteria/ac-0042.md @@ -0,0 +1,16 @@ +--- +id: "ac-0042" +status: active +--- + +# 활성 service token name-only rename와 identity 보존 + +## Criterion + +존재하는 활성 service token을 기존 이름에서 새 이름으로 rename하면 동일한 durable token identity의 `name`만 변경되어야 한다. 성공 직후 기존 이름의 active lookup은 더 이상 해당 token을 반환하지 않고 새 이름의 lookup은 동일한 token을 반환해야 하며, rename 전에 유효했던 bearer token은 같은 principal·permission·runtime/daemon scope로 계속 인증되어야 한다. Rename transaction 자체는 `id`, token secret/hash/prefix, owner, permission, token type, expiry, `last_used_at`/revocation timestamps, runtime/daemon binding을 포함한 `name` 외 모든 저장 값을 변경하지 않아야 한다. 후속 인증이 기존 인증 계약에 따라 `last_used_at`을 갱신하는 것은 rename mutation이 아니다. Revoked token은 rename 대상이 아니다. + +## Verification + +- 성공 조건: 실제 PostgreSQL에서 활성 token을 rename한 직후, 후속 인증 전에 before/after row를 비교하여 `name` 외 모든 column이 동일하고 old-name lookup이 비어 있으며 new-name lookup이 같은 token identity를 반환한다. 그 다음 기존 bearer token으로 인증하여 같은 principal을 확인하며, 이 인증으로 인한 기존 계약상의 `last_used_at` 갱신만 허용한다. +- 실패·경계 조건: old name의 활성 token이 없으면 `서비스 토큰을 찾을 수 없습니다: NAME`으로 실패하고 active/revoked row가 모두 변경되지 않는다. 같은 이름의 revoked row만 존재해도 그 row를 rename 대상으로 선택하지 않는다. +- 검사 방법: repository/service unit test와 loopback real-PostgreSQL integration test로 persisted row, lookup, authentication principal 및 protected-field 불변성을 검증한다. Rename 경로가 token 생성·rotation·revoke 함수를 호출하면 test가 실패해야 한다. diff --git a/.proofline/criteria/ac-0043.md b/.proofline/criteria/ac-0043.md new file mode 100644 index 0000000..b17a6f8 --- /dev/null +++ b/.proofline/criteria/ac-0043.md @@ -0,0 +1,16 @@ +--- +id: "ac-0043" +status: active +--- + +# rename 충돌·동시성·same-name 원자성 + +## Criterion + +Service-token rename은 active service-token name uniqueness를 원자적으로 유지해야 한다. 새 이름을 다른 active service token이 사용 중이면 `이미 존재하는 서비스 토큰 이름입니다: NEW_NAME`으로 실패하며 어느 token도 일부 변경되지 않아야 한다. 같은 target name을 두 rename 또는 rename과 issue가 동시에 획득하려 할 때 database uniqueness가 순서를 결정하여 최대 한 작업만 성공하고 나머지는 같은 domain conflict로 귀결되어야 한다. 새 이름과 기존 이름이 같으면 활성 token의 존재를 확인한 뒤 성공 no-op(`changed=false`)로 처리하고 durable write를 수행하지 않아야 한다. 동일 이름의 revoked token만 존재하는 경우에는 활성 rename을 막지 않아야 한다. + +## Verification + +- 성공 조건: unused target과 revoked-only target rename이 성공하고, same-name request는 동일 token 정보를 `changed=false`로 반환하며 repository write count가 0이다. +- 실패·경계 조건: active target collision 및 concurrent target collision은 명시된 duplicate-name error를 반환하고 source/target row의 `name`과 보호 필드를 rollback 전후 동일하게 유지한다. Missing same-name request는 no-op 성공이 아니라 missing-token error다. +- 검사 방법: service fake/spy test로 no-op write absence를 검증하고, loopback real-PostgreSQL test에서 active/revoked collision과 독립 connection을 사용한 rename-vs-rename 및 rename-vs-issue 경쟁을 실행하여 성공 수, conflict message, persisted final rows를 검증한다. diff --git a/.proofline/criteria/ac-0044.md b/.proofline/criteria/ac-0044.md new file mode 100644 index 0000000..e1395e4 --- /dev/null +++ b/.proofline/criteria/ac-0044.md @@ -0,0 +1,16 @@ +--- +id: "ac-0044" +status: active +--- + +# service-token rename CLI 확인·출력·회귀 계약 + +## Criterion + +CLI는 `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`를 제공해야 한다. 기본 실행은 old/new name을 식별하는 확인 prompt를 표시하고 거부 시 성공 exit로 종료하면서 어떠한 rename도 수행하지 않아야 하며, `--force/-f`는 prompt 없이 같은 동작을 수행해야 한다. 성공한 table 및 JSON 출력은 `token_id`, `old_name`, `new_name`, `changed`만 표현하고 raw/masked token, token hash/prefix나 다른 secret-derived 값을 포함하지 않아야 한다. Missing/duplicate domain error는 기존 CLI error contract에 따라 non-zero로 반환되어야 하며 기존 `list`, `issue`, `rotate`, `rebind`, `revoke`, `migrate-null-daemon`의 동작은 유지되어야 한다. + +## Verification + +- 성공 조건: command help와 required `--new-name`가 노출되고, 승인 prompt와 `--force` 실행의 table/JSON이 정확한 네 필드 및 `changed` 상태를 반환한다. Same-name 결과도 `changed=false`로 직렬화된다. +- 실패·경계 조건: prompt 거부는 exit code 0과 zero service/repository call을 보장한다. Missing/duplicate는 exit code 1과 지정된 message를 반환한다. 모든 success/error 출력에 raw token, hash, prefix와 credential이 없어야 한다. +- 검사 방법: Typer CLI runner test로 help, missing option, prompt accept/reject, `--force`와 `-f`, table/JSON, same-name, missing/duplicate를 검증하고 기존 service-token CLI regression suite 및 full test suite를 실행한다. diff --git a/.proofline/identities.json b/.proofline/identities.json index 49885af..aa2aba9 100644 --- a/.proofline/identities.json +++ b/.proofline/identities.json @@ -1,5 +1,5 @@ { "schema_version": 1, - "next_line_number": 10, - "next_ac_number": 42 + "next_line_number": 11, + "next_ac_number": 45 } diff --git a/.proofline/lines/line-0010/dcy-0010.md b/.proofline/lines/line-0010/dcy-0010.md new file mode 100644 index 0000000..48a044a --- /dev/null +++ b/.proofline/lines/line-0010/dcy-0010.md @@ -0,0 +1,51 @@ +--- +id: "dcy-0010" +status: confirmed +--- + +# Service token rename without secret rotation + +## Problem + +`pfhub service-token`은 활성 service token을 이름으로 조회해 rotate, rebind, revoke할 수 있지만 이름만 바꾸는 명령은 제공하지 않는다. 이름 오타나 명명 규칙을 교정하려면 새 token을 발급하고 기존 token을 revoke해야 하므로, label 변경만 필요한 경우에도 raw bearer token과 client 배포를 교체해야 한다. + +기대 상태는 활성 service token의 동일한 durable row에서 `name`만 원자적으로 변경하고 기존 bearer token, identity, owner, permission, expiry, runtime/daemon binding을 유지하는 것이다. CLI는 확인 prompt와 non-interactive `--force`를 제공하고 table/JSON에서 secret 없이 변경 결과를 반환해야 한다. + +## Evidence + +- `E-001` GitHub Issue #5 (`https://github.com/fourblocks2020/portfolio-hub/issues/5`)는 `pfhub service-token rename NAME --new-name NEW_NAME [--force]`와 name-only 변경, active-name uniqueness, missing/duplicate/no-op 오류, table/JSON 출력을 요구한다. 원문은 commit `17d6cbe3ba4ec10842bafb4af90709ef83fe721f`의 `docs/issues/issue-0005-original.md`에 byte-for-byte 보존되어 있다. +- `E-002` `app/cli/commands/service_tokens.py`의 current command set은 `list`, `issue`, `rotate`, `rebind`, `revoke`, `migrate-null-daemon`이며 rename path가 없다. `revoke_service_token()`은 `--force/-f`와 `typer.confirm()`의 기존 UX를 제공한다. +- `E-003` `app/services/auth_service.py::AuthService`는 active service token lookup, issue/rotate/rebind/revoke lifecycle을 소유한다. `issue_service_token()`은 PostgreSQL `UniqueViolation`을 `이미 존재하는 서비스 토큰 이름입니다: {name}`으로 변환한다. +- `E-004` `app/repositories/postgres.py::get_service_token_by_name()`은 `name`, `token_type='service'`, `revoked_at IS NULL`로 active row만 조회한다. 같은 파일의 cold-start schema와 migration `app/db/alembic/versions/20260520_0022_service_token_unique_index_fix.py`는 `idx_auth_tokens_service_name_unique` partial unique index로 active service-token name uniqueness를 이미 강제한다. +- `E-005` `app/repositories/portfolio_repository.py::PortfolioRepository`에는 rename contract가 없으며 `app/schemas/auth.py`에도 rename CLI response가 없다. +- `E-006` `AuthToken`/PostgreSQL row는 token identity와 secret-derived 값, permission, owner, expiry, runtime/daemon binding을 name과 별도 column으로 보존한다. raw token은 저장하지 않으므로 name-only invariant는 row before/after 비교와 기존 bearer token 재인증으로 검증해야 한다. +- `E-007` current `main` baseline `996127f2155a71c1085c4fbf35b504cc3c43439c`에서 관련 구현이 없고, Issue #5 및 `service token rename` keyword의 기존 구현 PR도 없다. +- `E-008` 현재 ProofLine active AC 검색 결과 service-token label rename lifecycle을 소유하는 criterion은 없다. service-token authorization 및 daemon-scope criteria는 회귀 대상일 뿐 이 신규 capability를 satisfy하지 않는다. + +## Scope + +- `S-001` AC admission은 durable CLI behavior를 소유하는 신규 REQ/AC `create`로 분류한다. Approved REQ 원문과 기존 active AC 의미는 변경하지 않는다. +- `S-002` `PortfolioRepository`와 PostgreSQL adapter에 active service token의 `name`만 바꾸는 단일 `UPDATE ... RETURNING` contract를 추가한다. 새 이름 사전 조회를 uniqueness 권위로 사용하지 않고 기존 partial unique index가 concurrent conflict를 결정한다. +- `S-003` `AuthService`에 missing active token, duplicate active name, same-name success no-op를 구분하는 rename contract를 추가한다. `new_name == name`도 active row 존재를 확인하며 repository write는 하지 않는다. +- `S-004` `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`를 추가한다. prompt 취소는 success exit와 zero write이고, 결과는 `token_id`, `old_name`, `new_name`, `changed`만 table/JSON으로 출력한다. +- `S-005` 성공 rename은 동일 row의 `name` 외 모든 column과 기존 bearer-token authentication principal을 보존한다. raw/masked token을 출력하거나 token generation/hash/revoke 경로를 호출하지 않는다. +- `S-006` unit/service/CLI와 loopback real-PostgreSQL integration으로 success, missing, active collision, revoked-name reuse, same-name no-op, prompt/force, secret non-disclosure, protected-field invariants와 concurrent conflict를 검증한다. +- `S-007` existing interface documentation과 CLI examples를 additive contract에 맞춰 갱신하며 기존 service-token lifecycle/auth/daemon-scope 회귀를 유지한다. + +## Out of Scope + +- `OS-001` raw token rotation, reissue, revoke 또는 secret 표시 +- `OS-002` token owner, permission, type, expiry, runtime/daemon binding 변경 +- `OS-003` revoked token row 자체의 rename +- `OS-004` service-token name 문자셋·길이·정규화에 대한 신규 정책 +- `OS-005` HTTP API endpoint, database schema migration, historical backfill +- `OS-006` 다른 repository 변경, production rollout 또는 production token mutation + +## Risks and Unknowns + +### Risks + +- `R-001` 애플리케이션의 “이름 사용 가능” 사전 조회만으로 충돌을 판단하면 concurrent rename/issue가 race한다. single-statement update와 partial unique index를 authority로 사용하고 실제 PostgreSQL 경쟁 테스트로 완화한다. +- `R-002` update projection이나 CLI response에 secret-derived/internal field가 포함되면 정보 노출 또는 unintended mutation이 생긴다. canonical auth-token projection을 반환하되 CLI response를 네 개의 비밀이 아닌 필드로 제한하고 negative test를 둔다. +- `R-003` same-name no-op가 존재 확인 없이 성공하면 missing token을 정상 처리하거나 revoked row를 오인할 수 있다. active lookup 성공을 no-op의 precondition으로 고정한다. +- `R-004` application rollback은 이미 완료된 metadata rename을 자동으로 되돌리지 않는다. 필요한 경우 동일 command로 이전 이름에 다시 rename하며 production 실행은 별도 운영 승인으로 둔다. diff --git a/.proofline/lines/line-0010/evidence/activity-log.md b/.proofline/lines/line-0010/evidence/activity-log.md new file mode 100644 index 0000000..e906c0c --- /dev/null +++ b/.proofline/lines/line-0010/evidence/activity-log.md @@ -0,0 +1,25 @@ +# Activity Log + +## 2026-09-19 — implementation start + +- 사용자 승인: `req-0010` approved, `ac-0042`~`ac-0044` active. +- 계획: repository protocol/PostgreSQL atomic rename, service same-name no-op, CLI response/command, docs와 unit/real-PostgreSQL tests를 구현한다. +- 범위 제외: schema migration, HTTP API, production token mutation/rollout. +- 다음 행동: 실패하는 focused tests를 먼저 추가하고 최소 구현 후 전체 검증한다. + +## 2026-09-19T21:00:42+09:00 — implementation verified + +- 구현: active service token을 단일 PostgreSQL `UPDATE ... RETURNING`으로 rename하고 service same-name no-op, duplicate domain error, Typer confirmation/`--force/-f`, secret-free table/JSON response, README 예시를 추가했다. +- focused test: rename service/CLI selector `9 passed`; real-PostgreSQL rename suite `5 passed`. +- 실제 backend: current migrations 적용 후 보호 필드·bearer principal 보존, active/revoked/missing 경계, duplicate rollback, rename-vs-rename 및 rename-vs-issue independent-connection 경쟁을 검증했다. +- CI selector: 기존 PostgreSQL workflow 단계에 `tests/test_service_token_rename_postgres.py`를 등록하고 release contract test로 runtime password-variable 조합과 selector 존재를 검증했다. Corporate-action 및 rename PostgreSQL selector 합계 `11 passed`. +- sabotage: SQL rename parameter를 일시적으로 old name으로 대체했을 때 persisted-name assertion이 의도대로 실패했고 복원 후 동일 selector `1 passed`였다. +- 전체 회귀: `742 passed, 26 skipped`; `proofline validate`, `git diff --check`, compileall 및 신규 PostgreSQL 파일 Ruff F/I 검사가 통과했다. Ruff ratchet은 commit 전 working tree를 포함하지 않으므로 commit 후 canonical selectors를 다시 실행한다. +- 독립 리뷰: runtime/test/security 두 검토의 구현 의미·coverage 평가는 적합했다. DSN 표시 관련 blocker는 tool credential-redaction을 literal로 오인한 false positive였으며, byte-level non-secret re-review에서 `${POSTGRES_PASSWORD}` 사용과 literal placeholder 부재를 확인하고 최종 `PASS`했다. +- lifecycle: approved `req-0010`과 active `ac-0042`~`ac-0044`는 기존 ProofLine 관례대로 유지한다. production token mutation 및 rollout은 수행하지 않았다. + +## 2026-09-19T21:13:04+09:00 — CI portability correction + +- PR #40 최초 CI는 Python 3.11/Rich의 ANSI escape가 required-option stderr의 `--new-name` 내부에 삽입되어 literal substring assertion 한 건이 실패했다. CLI exit code와 required-option 동작은 정상이며 production implementation 결함은 아니었다. +- 테스트를 `click.unstyle()`로 stderr 정규화 후 option contract를 확인하도록 수정했다. +- CI와 같은 Python 3.11 및 `uv sync --extra dev --locked` 환경에서 실패 selector `1 passed`, 전체 suite `731 passed, 37 skipped`, 실제 PostgreSQL selector `11 passed`, Ruff ratchet 3종 `introduced_findings=0`, ProofLine validation과 diff check 통과를 확인했다. diff --git a/.proofline/lines/line-0010/line-0010.md b/.proofline/lines/line-0010/line-0010.md new file mode 100644 index 0000000..6112f13 --- /dev/null +++ b/.proofline/lines/line-0010/line-0010.md @@ -0,0 +1,4 @@ +--- +id: "line-0010" +status: discovery +--- diff --git a/.proofline/lines/line-0010/req-0010.md b/.proofline/lines/line-0010/req-0010.md new file mode 100644 index 0000000..1214770 --- /dev/null +++ b/.proofline/lines/line-0010/req-0010.md @@ -0,0 +1,44 @@ +--- +id: "req-0010" +status: approved +discovery: "dcy-0010" +criteria: + create: + - "ac-0042" + - "ac-0043" + - "ac-0044" + update: [] + retire: [] + satisfy: [] +--- + +# Service token rename without secret rotation + +## Objective + +운영자가 활성 service token의 bearer token과 권한·binding을 교체하지 않고 식별용 이름만 안전하게 변경할 수 있게 한다. 변경은 concurrent issue/rename과 경쟁하더라도 기존 active-name uniqueness를 지키며, CLI는 interactive와 automation 환경 모두에서 secret을 노출하지 않는 명확한 결과를 제공해야 한다. + +## Scope + +- 활성 service token 한 건의 `name`만 동일 durable identity에서 변경한다. +- 기존 active service-token name uniqueness를 concurrent conflict의 최종 권위로 사용하고 충돌은 전체 rename을 실패시킨다. +- 존재하지 않는 활성 이름, 같은 이름, revoked-name reuse를 서로 구분해 안정적으로 처리한다. +- `pfhub service-token rename NAME --new-name NEW_NAME [--force/-f]`와 table/JSON 결과를 제공한다. +- repository, service, CLI, 문서와 unit/real-PostgreSQL 검증을 함께 갱신한다. + +## Constraints + +- Rename transaction 자체는 `id`, token secret/hash/prefix, owner, permission, token type, expiry, `last_used_at`/revocation timestamps, runtime/daemon binding 등 `name` 외 저장 필드를 변경하지 않는다. +- rename은 token rotation, issue, revoke 경로를 호출하지 않고 기존 bearer token의 authentication principal을 보존한다. Rename 이후의 인증은 기존 인증 계약에 따라 `last_used_at`을 갱신할 수 있으며 이는 rename mutation에 포함되지 않는다. +- duplicate-name 판단은 애플리케이션의 사전 조회가 아니라 PostgreSQL partial unique index를 최종 권위로 삼으며, 실패 시 partial mutation이 없어야 한다. +- raw token, token hash 또는 connection credential은 CLI table/JSON, error, log, test evidence에 출력하지 않는다. +- 기존 migration `20260520_0022`의 partial unique index를 사용하며 신규 schema migration이나 backfill을 추가하지 않는다. +- 현재 `service-token` command와 HTTP API의 기존 의미 및 응답 계약을 변경하지 않는다. + +## Non-Goals + +- revoked token row rename +- service-token name의 신규 문자셋·길이·정규화 정책 +- owner, permission, expiry, runtime/daemon binding 변경 +- HTTP rename endpoint 또는 다른 repository의 producer/consumer 변경 +- production token rename 또는 production rollout diff --git a/README.md b/README.md index 2f007ab..342b525 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ pfhub service-token list --user alice pfhub service-token issue nightly --user alice --ttl-days 30 pfhub service-token issue permanent --user alice --no-expiry pfhub service-token issue my-daemon-token --user alice --daemon-id +pfhub service-token rename nightly --new-name nightly-kis pfhub service-token revoke nightly --force pfhub service-token rotate permanent --force pfhub daemon create alpha-bot --user alice --display-name "Alpha Trading Bot" @@ -137,6 +138,7 @@ Supported commands: - `user delete` - `service-token list` - `service-token issue` +- `service-token rename` - `service-token revoke` - `service-token rotate` - `daemon create` diff --git a/app/cli/commands/service_tokens.py b/app/cli/commands/service_tokens.py index 47a792b..334b8c7 100644 --- a/app/cli/commands/service_tokens.py +++ b/app/cli/commands/service_tokens.py @@ -8,7 +8,13 @@ import typer from app.cli.core.output import mask_token_text, render_service_token_result from app.cli.core.utils import render_output -from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRebindCliResponse, ServiceTokenRevokeCliResponse +from app.schemas.auth import ( + AuthTokenResponse, + ServiceTokenIssueCliResponse, + ServiceTokenRebindCliResponse, + ServiceTokenRenameCliResponse, + ServiceTokenRevokeCliResponse, +) from app.services.auth_service import AuthService token_app = typer.Typer(help="Service token lifecycle commands.") @@ -92,6 +98,32 @@ def issue_service_token( ctx.obj.console.print("[yellow]⚠ 이 토큰은 다시 표시되지 않습니다. 지금 복사해 안전한 곳에 저장하세요.[/yellow]") +@token_app.command("rename") +def rename_service_token( + ctx: typer.Context, + name: str = typer.Argument(..., help="Active service token name to rename."), + new_name: str = typer.Option(..., "--new-name", help="New unique service token name."), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."), +) -> None: + """Rename an active service token without rotating its bearer token.""" + if not force: + confirmed = typer.confirm(f"서비스 토큰 이름을 '{name}'에서 '{new_name}'(으)로 변경하시겠습니까?") + if not confirmed: + raise typer.Exit(code=0) + + def _rename(service: AuthService, app_context): + return service.rename_service_token(name, new_name) + + renamed = _with_service(ctx, _rename) + response = ServiceTokenRenameCliResponse( + token_id=renamed.auth_token.id, + old_name=name, + new_name=renamed.auth_token.name, + changed=renamed.changed, + ) + render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) + + @token_app.command("rotate") def rotate_service_token( ctx: typer.Context, diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 1598bad..21e098c 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -87,6 +87,8 @@ class PortfolioRepository(Protocol): def get_service_token_by_name(self, name: str) -> AuthToken | None: ... + def rename_service_token(self, name: str, new_name: str) -> AuthToken: ... + def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ... def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 4ea9a6b..433c764 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1128,6 +1128,37 @@ class PostgresPortfolioRepository: return None return self._row_to_auth_token(row) + def rename_service_token(self, name: str, new_name: str) -> AuthToken: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + UPDATE auth_tokens + SET name = %s + WHERE name = %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, + daemon_id + """, + (new_name, name), + ) + row = cur.fetchone() + if row is None: + raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") + return self._row_to_auth_token(row) + def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: self.ensure_schema() with self._connect().cursor() as cur: diff --git a/app/schemas/auth.py b/app/schemas/auth.py index b4952fb..fb9d0c0 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -90,6 +90,13 @@ class ServiceTokenRebindCliResponse(BaseModel): previous_daemon_id: str | None = None +class ServiceTokenRenameCliResponse(BaseModel): + token_id: str + old_name: str + new_name: str + changed: bool + + class ServiceTokenRevokeCliResponse(BaseModel): token_id: str name: str diff --git a/app/services/auth_service.py b/app/services/auth_service.py index b5176b6..7ee2d66 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -35,6 +35,12 @@ class IssuedServiceToken: auth_token: AuthToken +@dataclass +class RenamedServiceToken: + auth_token: AuthToken + changed: bool + + def get_auth_service_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository: return get_portfolio_repository_from_settings(settings) @@ -130,6 +136,19 @@ class AuthService: raw_token, auth_token = self._repository.rotate_service_token(name) return IssuedServiceToken(token=raw_token, auth_token=auth_token) + def rename_service_token(self, name: str, new_name: str) -> RenamedServiceToken: + if name == new_name: + existing = self._repository.get_service_token_by_name(name) + if existing is None: + raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") + return RenamedServiceToken(auth_token=existing, changed=False) + + try: + renamed = self._repository.rename_service_token(name, new_name) + except psycopg.errors.UniqueViolation as exc: + raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {new_name}") from exc + return RenamedServiceToken(auth_token=renamed, changed=True) + def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]: existing = self._repository.get_service_token_by_name(name) if existing is None: diff --git a/docs/issues/issue-0005-original.md b/docs/issues/issue-0005-original.md new file mode 100644 index 0000000..c9a6ee0 --- /dev/null +++ b/docs/issues/issue-0005-original.md @@ -0,0 +1,68 @@ + +- **유형**: Feature / Enhancement +- **우선순위**: Medium +- **영역**: CLI / 인증 (`pfhub service-token`) +- **대상 버전**: portfoliohub 0.1.0 (commit `58899b1`) + +## 배경 / 문제 + +현재 `pfhub service-token` 하위 명령은 `list` / `issue` / `rotate` / `rebind` / +`revoke` / `migrate-null-daemon` 6개뿐이며, **기존 서비스 토큰의 `name` 을 변경하는 명령이 없다.** + +- `name` 은 `rotate` / `rebind` / `revoke` 의 **조회 키**로 쓰인다 + (`app/cli/commands/service_tokens.py` — 각 명령이 `name` 인자로 토큰을 찾음). +- 이름을 바꾸려면 현재로선 `issue`(새 이름으로 신규 발급) → `revoke`(기존 폐기)가 유일한 우회 경로다. +- 이 우회는 **토큰 값(평문)까지 새로 바뀌므로**, 단순히 라벨만 정리하려는 경우에도 + 모든 클라이언트·데몬의 토큰을 재배포해야 한다. 이름 오타 수정, 명명 규칙 정비 등에 과도한 비용이 든다. + +## 요구사항 + +기존 서비스 토큰의 `name` 만 변경하고 **토큰 값·바인딩은 그대로 유지**하는 CLI 명령을 추가한다. + +### 제안 인터페이스 + +``` +pfhub service-token rename NAME --new-name NEW_NAME [--force] +``` + +- `NAME` (인자): 현재 서비스 토큰 이름 (조회 키) +- `--new-name` (필수 옵션): 변경할 새 이름 +- `--force` (옵션): 확인 프롬프트 생략 (`revoke` 명령과 동일 패턴) + +## 수용 기준 (Acceptance Criteria) + +1. `NAME` 으로 활성(비폐기) 서비스 토큰을 찾아 `name` 을 `NEW_NAME` 으로 변경한다. +2. 다음 값은 **변경되지 않는다**: 토큰 값(평문)·`token_hash`·`token_prefix`· + `permission`·`token_type`·`daemon_id`·`runtime_id`·`expires_at`·`token_id`. +3. 이름 유일성 검증: `NEW_NAME` 이 다른 활성 서비스 토큰과 충돌하면 명확한 에러로 거부한다. + - DB에는 이미 부분 유니크 인덱스가 존재한다: + `idx_auth_tokens_service_name_unique ON auth_tokens(name) WHERE token_type='service' AND revoked_at IS NULL` + (`app/repositories/postgres.py:190-193`) — `UniqueViolation` 을 사용자 메시지로 변환할 것. +4. 에러 처리: + - `NAME` 에 해당하는 활성 서비스 토큰이 없으면 → "서비스 토큰을 찾을 수 없습니다: NAME" + - `NEW_NAME` 이 이미 사용 중이면 → "이미 존재하는 서비스 토큰 이름입니다: NEW_NAME" + - `NEW_NAME == NAME` 이면 → 변경 없음 안내 후 정상 종료 +5. 출력: `token_id`, 기존 `name`, 변경된 `name` 을 표시 (table / json 양쪽 지원). +6. `--force` 미지정 시 변경 전 확인 프롬프트를 띄운다. + +## 구현 범위 (참고) + +- CLI: `app/cli/commands/service_tokens.py` 에 `rename` 명령 추가. +- 서비스: `app/services/auth_service.py` 에 `rename_service_token(name, new_name)` 추가 + — `issue_service_token` 과 동일하게 `psycopg.errors.UniqueViolation` 을 `ValueError` 로 변환. +- 리포지토리: `app/repositories/postgres.py` 에 `UPDATE auth_tokens SET name=%s WHERE ...` + 메서드 추가 및 `app/repositories/portfolio_repository.py` 프로토콜에 시그니처 추가. +- 스키마: `app/schemas/auth.py` 에 rename 응답용 CLI 모델 추가 (기존 `ServiceToken*CliResponse` 패턴 따름). +- 테스트: 정상 변경 / 미존재 토큰 / 이름 충돌 / 동일 이름 케이스. + +## 범위 외 (Out of Scope) + +- 토큰 값 회전(rotation)은 본 기능에 포함하지 않는다. 값 변경이 필요하면 기존 `rotate` 를 사용한다. +- 폐기된(revoked) 토큰의 이름 변경은 지원하지 않는다 (유일성 인덱스가 활성 토큰만 대상으로 함). +- 서비스 토큰 이름 형식(문자셋) 검증 규칙은 현재 `issue` 에도 없으므로 본 이슈에서 신규 도입하지 않는다. + (별도 정비가 필요하면 분리 이슈로 다룬다.) + +## 비고 + +- 관련: service-token 마스킹 이슈(해결 완료). 본 기능은 토큰 값을 노출/변경하지 않으므로 마스킹과 무관하다. +- governance: 신규 CLI 명령 추가이므로 `draft-req` → batch delivery 흐름 대상으로 검토. diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py index 0989152..55b02be 100644 --- a/tests/release/test_release_contracts.py +++ b/tests/release/test_release_contracts.py @@ -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 diff --git a/tests/test_auth.py b/tests/test_auth.py index bc5cb1f..f1ed783 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index a68f88b..b916e1b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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") diff --git a/tests/test_service_token_rename_postgres.py b/tests/test_service_token_rename_postgres.py new file mode 100644 index 0000000..8e8a4b6 --- /dev/null +++ b/tests/test_service_token_rename_postgres.py @@ -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