diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e4a89c..fe578f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ スナップショットの名前の検証を、位置引数のプロジェクト名と同じ規則(`utils/names`)へ寄せました。 それ以外に受け付ける名前と、エラーの文言は変わりません。 +### Fixed +- **`group_aliases` のある置き場で、機密の参照の見出しがグループの読み替えの前と後を出すように + しました(PLAN64 / #188)。** `devbase env list` の節の見出しと件数の行、`devbase env backend test` + の参照ごとの行、`devbase env backend migrate` の移行の計画の一覧と `--to age` の完了後の一覧が、 + `グローバル(グループ default)` から `グローバル(グループ default → nyle)` になります。これまでは + 読み替える前の名前だけが出て、隣に並ぶパス(`devbase/team/nyle/global`)と食い違って見えていました。 + 読み替えの対応が無いグループ・`version: 1` ・ファイル backend(`plaintext` / `age`)の見出しと、 + エラー文言・警告・ログ・`devbase env backend status` の表示は変わりません。置き場のパス・サーバへの + 要求・キャッシュにも影響しません。 + ## [3.6.0] - 2026-09-19 ### Added diff --git a/docs/specifications/secret-backend.md b/docs/specifications/secret-backend.md index de5ba177..adbfa37c 100644 --- a/docs/specifications/secret-backend.md +++ b/docs/specifications/secret-backend.md @@ -282,7 +282,11 @@ DEVBASE_ACCOUNT_GROUP{付ける引数}{実行場所} `for_project` が `group=` を受けて同じ規則で検証する。グループは参照の等価性に入り、1 つの `SecretStore` の中でグループの違う参照の控え(取得した内容と版)を取り違えない。グループを `SecretStore` のインスタンスに持たせないのは、ストアがプロジェクトの切替をまたいで持ち回られる -ためである。`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける。 +ためである。`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける。**引数なしで呼ぶと +括弧に入るのは読み替える前の名前**で、`label(group_display=...)` を渡すと括弧の中だけがその文字列に +差し替わる。既定を読み替える前の名前にしているのは、読み替えの解決(`storage_group`)がグループ名の +検証と予約語の検査で `BackendConfigError` を送出しうるためである。誤りを伝える文言とログがこの解決を +背負うと、文言を組み立てる途中で新しい例外が起き、元の失敗が利用者へ届かなくなる。 `SecretStore.ref_group(project)` は、backend が `openbao` かつ `layout: group` のときだけ `declared_group(root, project)` を返し、それ以外は `None` を返す。`version: 1` とファイル @@ -302,7 +306,17 @@ backend では参照のグループが常に空で、参照の値・等価性・ (`team//…` が `version: 1` の `team/global` / `team/projects/` と重なるため)。 `global` を読み替え元にする対応は受け付ける。2 つのグループが同じ置き場かは読み替えた後の 名前で比べる(`SecretStore.same_storage_group`)。文言には読み替えの前と後を `default → nyle` -の形で出す(`display_group`)。 +の形で出す(`display_group`)。**除くのは、引数なしの `SecretRef.label()` で参照を表示する +エラー文言・警告・ログだけ**で、そこには読み替える前の名前が出る(前項)。`display_group` を直接 +呼ぶ文言は、エラーであっても前と後を出す(`--group` がプロジェクトのグループと違う置き場である旨の +文言など)。 + +正常系の一覧の見出しは `SecretStore.display_label(ref)` を通す。見出し用の表示を作る口はこれ 1 つで、 +読み替えの要否は `SecretStore.storage_group(ref.group)` が決める(`None` を返せば `label()` をそのまま +返す)。backend の種類を `config.openbao is None` では判定しない。`backend: age` の設定に `openbao:` 節が +残っていれば `None` にならないためである。`display_label` を通る見出しは、`env list` の節の見出しと +件数の行・`env backend test` の参照ごとの行・`env backend migrate` の移行の計画の一覧と `--to age` の +完了後の一覧の 5 か所である。 **`default` の読み替えを置き場の上だけで行う理由。** `DEVBASE_ACCOUNT_GROUP` の既定値を変えると ボリューム名 `devbase_home_default` が変わり、既存の認証と会話ログのボリュームを移すことになる。 @@ -337,7 +351,10 @@ backend では参照のグループが常に空で、参照の値・等価性・ グループが `with` なら `up` はそこを読まず、書けたように見えて使われない機密が残るためである。 文言でプロジェクトの `env` の `DEVBASE_ACCOUNT_GROUP` を直すよう案内する。`list` の見出しは `=== グローバル(グループ with) (...) ===` / `=== プロジェクト: web(グループ with) (...) ===` -の形になる(`version: 1` ではグループが付かない)。 +の形になる(`version: 1` ではグループが付かない)。`group_aliases` に対応のあるグループでは、 +読み替えの前と後が並んで `=== グローバル(グループ default → nyle) (...) ===` / +`=== プロジェクト: web(グループ default → nyle) (...) ===` になり、隣に並ぶパス +(`devbase/team/nyle/global`)と同じグループを指していると読める。 #### dispatch 前の注入 diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index 3b295cd0..8bf46b43 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -75,7 +75,7 @@ devbase env list [-g|-p] [-r] [-k] [--user] [--group NAME] | `-r` | 値も表示(デフォルトではキーのみ) | | `-k` | キー名でソート | | `--user` | 個人単位の置き場だけを表示(サーバ backend のみ) | -| `--group NAME` | 対象のグループを指定(グループ別の置き場のみ)。見出しにグループ名が付く(例: `=== グローバル(グループ kkg) ...`) | +| `--group NAME` | 対象のグループを指定(グループ別の置き場のみ)。見出しにグループ名が付く(例: `=== グローバル(グループ kkg) ...`。`group_aliases` で読み替えているグループは `=== グローバル(グループ default → nyle) ...`) | ```bash # グローバル変数のみ、値付きで表示 diff --git a/docs/user/env-backend.md b/docs/user/env-backend.md index 07b75bd7..34960026 100644 --- a/docs/user/env-backend.md +++ b/docs/user/env-backend.md @@ -369,6 +369,9 @@ devbase env set --user --group kkg KEY=value `DEVBASE_ACCOUNT_GROUP` を直してください。 `env list` の見出しにはグループが付きます(例: `=== グローバル(グループ kkg) (...) ===`)。 +`group_aliases` で読み替えているグループでは、読み替えの前と後が並びます +(例: `=== グローバル(グループ default → nyle) (...) ===`)。`env backend test` の一覧も同じ形で、 +見出しのグループ名と隣のパス(`devbase/team/nyle/global`)が同じグループを指します。 ### `init` / `sync` / `project` / `export` / `import` diff --git a/issues/PLAN64_secret-label-group-impl.md b/issues/PLAN64_secret-label-group-impl.md new file mode 100644 index 00000000..5dd9e97a --- /dev/null +++ b/issues/PLAN64_secret-label-group-impl.md @@ -0,0 +1,145 @@ +# PLAN64: 機密の参照の見出しにグループの読み替えを出す(実装) + +## 関連リンク + +- 課題: devbasex/devbase#188 +- 要求と受け入れ条件: [PLAN64_secret-label-group.md](PLAN64_secret-label-group.md) +- 設計: [PLAN64_secret-label-group-design.md](PLAN64_secret-label-group-design.md)(設計 Pull Request #223) +- まとまり: release Pull Request #212(`release/v3.7.0`) + +## モード + +`standard`。利用者が読む公開の出力の振る舞いを変え、確定仕様の自己矛盾を解くため。 + +## 目的と非目的 + +達成したい状態: + +- `group_aliases` のある端末で、見出しのグループ名(`default → nyle`)と隣のパス(`…/team/nyle/…`)が + 同じグループを指していると読める +- 参照の表示の文言を組み立てる場所が `SecretRef.label()` の 1 つに保たれる +- 確定仕様の 2 つの記述が、文言の種類で重ならない形に分かれる + +やらないこと: + +- エラー文言・警告・ログ・巻き戻しの説明に出る `label()` の表示(43 か所。引数なしのまま) +- `env backend status` の表示(すでに `display_group` を使っている) +- `env encrypt` / `env decrypt` / `env rekey` の一覧(`ref.group` が常に `None` で出力が変わらない) +- 一覧の桁幅(`:<24` / `:<28` / `:<40`)の変更 +- `SecretRef` のフィールドと等価性、置き場のパス・サーバへの要求・キャッシュ + +## 前提 + +- 前提 1: `version: 1` とファイル backend では `SecretRef.group` が常に `None`(`SecretStore.ref_group` の契約) +- 前提 2: `config.openbao` が `None` かどうかで backend の種類を判定してはならない。 + `backend: age` でも `openbao:` 節が残っていれば `None` にならない。判定は `SecretStore.storage_group` に任せる +- 前提 3: `OpenBaoSettings.display_group` は `storage_group` を経由し `BackendConfigError` を送出しうる。 + 誤りを伝える文言の組み立ての中では呼ばない +- 前提 4: この端末の機密の置き場は本番の系である。確認はテストと読み取りに限り、書き込みを行わない + +## 受け入れ条件 + +要求文書の 12 件をそのまま引き継ぐ。検証手段は設計の「テスト設計」の表に対応する。 + +- [ ] 1. `env backend test` の見出しが `グローバル(グループ default → nyle)` / `個人のグローバル(グループ default → nyle)` + になり、隣のパスは今と同じ — 新規テスト +- [ ] 2. `env list` の節の見出しと件数の行(計 4 行)が読み替えの前後を出す — 新規テスト +- [ ] 3. `env backend migrate` の計画の一覧と `--to age` の完了後の一覧が、どちらも読み替えの前後を出す — 新規テスト 2 件 +- [ ] 4. 読み替えの対応が無いグループ(`kkg`)では `(グループ kkg)` のままで `→` が出ない — 新規テスト +- [ ] 5. `version: 1`(`layout: flat`)で出力全体に `(グループ` が 1 つも出ない — 新規テスト。既存 + `tests/env/test_groups.py` / `tests/env/test_runtime.py` が変更なしで通る +- [ ] 6. `openbao:` 節を残した `backend: age` でも出力全体に `(グループ` が出ない — 新規テスト。既存 + `tests/commands/test_env_user_axis.py` が変更なしで通る +- [ ] 7. 引数なしの `label()` を通すエラー文言・警告に `→` が出ない — 新規テスト 1 件 +- [ ] 8. `env backend status` の表示が今と同じ — 既存 `tests/commands/test_env_backend.py` が変更なしで通る +- [ ] 9・10・11. 確定仕様と利用者向け文書 — Pull Request のレビューで読んで確かめる +- [ ] 12. `uv run --locked pytest tests/ -q` が通り、結果を Pull Request 本文へ載せる + +## 代替案と採否 + +設計の「決定の記録」が正本。ここでは採否だけを控える。 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| A | `label()` に `group_display` を足し、`SecretStore.display_label` を唯一の口にする | 採用 | 文言が 1 か所に残り、エラー文言が読み替えの解決を背負わない(決定 1・2・3) | +| B | `label()` の既定を読み替え後にする | 不採用 | 43 か所のエラー文言・警告・ログが `BackendConfigError` を背負う(決定 2) | +| C | 見出しの側で `f'(グループ {display})'` を組み立てる | 不採用 | 同じ文言が 5 か所へ複製される(決定 1) | +| D | `config.openbao is None` で backend の種類を判定する | 不採用 | `backend: age` に節が残ると `None` にならない(決定 3、前提 2) | + +## 修正対象 + +| ファイル | 変更 | +| --- | --- | +| `lib/devbase/env/secret_store.py` | `SecretRef.label()` に `group_display` を足す / `SecretStore.display_label` を足す | +| `lib/devbase/commands/env.py` | `cmd_env_list` の共通の節 / `_group_suffix`(`store` を受ける) | +| `lib/devbase/commands/env_backend.py` | `cmd_env_backend_test` / `cmd_env_backend_migrate` の完了表示 / `_MigrationPlan._heading` | +| `tests/env/test_secret_store_label.py` | 新設。`label()` の契約と `display_label` の分岐 | +| `tests/commands/test_env_group_label.py` | 新設。読み替えのあるグループでのコマンドの出力 | +| `docs/specifications/secret-backend.md` | 2 つの記述の書き分けと `list` の見出しの例 | +| `docs/user/env-backend.md` / `docs/user/cli-reference/03-env.md` | 見出しの例に読み替えのある場合を足す | +| `CHANGELOG.md` | Unreleased の Fixed | + +## タスク分解 + +### Task 1: 表示の口を作る + +- **対象ファイル:** `lib/devbase/env/secret_store.py`、`tests/env/test_secret_store_label.py` +- **変更内容:** `SecretRef.label(*, group_display=None)` と `SecretStore.display_label(ref)` を足す。 + `display_label` は `storage_group(ref.group)` が `None` か読み替え無しなら `ref.label()` を返し、 + 読み替えがあるときだけ `display_group` の結果を渡す +- **満たす受け入れ条件:** 7、および 1・2・3・4・5・6 の土台 +- **進め方:** `label(group_display=...)` と `display_label` の 3 分岐を先に失敗するテストで固定してから実装する + +### Task 2: `env backend test` と `env list` の見出しを寄せる + +- **対象ファイル:** `lib/devbase/commands/env_backend.py`、`lib/devbase/commands/env.py`、 + `tests/commands/test_env_group_label.py` +- **変更内容:** `cmd_env_backend_test` の参照ごとの行と `cmd_env_list` の共通の節を `store.display_label(ref)` に、 + `_group_suffix` を `store` を受ける形に変える +- **満たす受け入れ条件:** 1・2・4・5・6 +- **進め方:** 読み替えのあるグループの出力を先に失敗するテストで固定してから差し替える + +### Task 3: `env backend migrate` の 2 つの一覧を寄せる + +- **対象ファイル:** `lib/devbase/commands/env_backend.py`、`tests/commands/test_env_group_label.py` +- **変更内容:** `_MigrationPlan._heading` を `self.server_store.display_label(...)` に、完了後の一覧を + `server_store.display_label(...)` にする +- **満たす受け入れ条件:** 3 +- **進め方:** 計画の一覧(`--dry-run`)と完了後の一覧(偽サーバ)を別々のテストで固定してから差し替える + +### Task 4: 文書を直す + +- **対象ファイル:** `docs/specifications/secret-backend.md`、`docs/user/env-backend.md`、 + `docs/user/cli-reference/03-env.md`、`CHANGELOG.md` +- **変更内容:** 確定仕様の 2 つの記述を文言の種類で分け、`list` の見出しの例に読み替えのある場合を足す。 + 利用者向け文書の例にも足す。CHANGELOG の Unreleased の Fixed へ 1 行 +- **満たす受け入れ条件:** 9・10・11 +- **進め方:** テスト駆動を適用しない(文書のため)。レビューで読んで確かめる + +## 影響範囲 + +`group_aliases` のある端末の 6 つの行の文字列だけが変わる。置き場のパス・サーバへの要求・キャッシュ・ +`backend.yml` の内容は変わらない。読み替えの対応が無いグループ、`version: 1`、ファイル backend の +出力はバイト単位で今と同じになる。 + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 既存テストが前方一致で見ており、見出しが変わっても通ってしまう | 「`(グループ` が 1 つも出ない」ことを見る出力テストを新しく置く(受け入れ条件 5・6) | +| `config.openbao` の `None` 判定に落ちる | 判定を `storage_group` の 1 か所に閉じ、`backend: age` に節を残した設定のテストで検査する | +| 実環境(本番の OpenBao)へ要求を出す | テストは隔離した `DEVBASE_ROOT` と偽の設定だけで行う。`env set` / `migrate` / backend の切り替えを実環境で行わない | +| `release/v3.7.0` を base にすると CI が動かない(#216) | 手元で `uv run --locked pytest tests/ -q` を走らせ、結果を Pull Request 本文へ載せる | + +触る対象(`secret_store.py` の `label` と `SecretStore`、2 つのコマンド)は責務が分かれており、 +先に構造を整える必要は無い。 + +## 切り戻し手順 + +コードの変更だけで、データの移行を伴わない。Pull Request を revert すれば元の文字列へ戻る。 + +## 完了の定義 + +- [ ] 受け入れ条件 1〜8 と 12 に、対応するテストの実行結果がある +- [ ] 9・10・11 は Pull Request のレビューで読んで確かめる +- [ ] `ruff check --select=E9,F63,F7,F82 lib` が通る diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index c32daa85..15a2642d 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -668,7 +668,7 @@ def cmd_env_list(devbase_root: Path, global_only: bool = False, if as_user and not env_file.file_exists(): continue all_vars = env_file.get_all() - label = env_file.ref.label() + label = store.display_label(env_file.ref) print(f"\n=== {label} ({env_file.path}{_mode_suffix(env_file)}) ===") _print_env_vars(all_vars, keys_only, reveal) @@ -680,7 +680,7 @@ def cmd_env_list(devbase_root: Path, global_only: bool = False, if proj_env is not None and proj_env.file_exists(): proj_vars = proj_env.get_all() label = '個人のプロジェクト' if as_user else 'プロジェクト' - suffix = _group_suffix(proj_env.ref) + suffix = _group_suffix(store, proj_env.ref) print(f"\n=== {label}: {proj_env.ref.name}{suffix} " f"({proj_env.path}{_mode_suffix(proj_env)}) ===") @@ -690,14 +690,16 @@ def cmd_env_list(devbase_root: Path, global_only: bool = False, return 0 -def _group_suffix(ref) -> str: - """見出しに付けるグループの表示 (``(グループ with)``)。グループの無い参照では空。 +def _group_suffix(store, ref) -> str: + """見出しに付けるグループの表示 (``(グループ default → nyle)``)。グループの無い参照では空。 - 文言は ``SecretRef.label()`` が持ち、ここでは写さずに差分だけを取り出す。 + 文言は ``SecretRef.label()`` が持ち、ここでは写さずに差分だけを取り出す。読み替えの + 有無は ``SecretStore.display_label`` が決める (PLAN64 決定 3)。グループを外した参照の + 表示は読み替えに左右されないため、差分はグループの部分だけになる。 """ from dataclasses import replace - return ref.label()[len(replace(ref, group=None).label()):] + return store.display_label(ref)[len(replace(ref, group=None).label()):] def _include_project_refs(devbase_root: Path, store, group: Optional[str], *, diff --git a/lib/devbase/commands/env_backend.py b/lib/devbase/commands/env_backend.py index 33886bd5..bc225f23 100644 --- a/lib/devbase/commands/env_backend.py +++ b/lib/devbase/commands/env_backend.py @@ -469,7 +469,7 @@ def cmd_env_backend_test(devbase_root: Path) -> int: print(f"対象のグループと違う置き場のプロジェクトは調べていません: {', '.join(skipped)}") print(f"読めた参照: {len(results)} 件") for ref, count in results: - print(f" {ref.label():<28} {backend.display_path(ref):<40} {count} 変数") + print(f" {store.display_label(ref):<28} {backend.display_path(ref):<40} {count} 変数") return 0 @@ -591,7 +591,8 @@ def cmd_env_backend_migrate(devbase_root: Path, *, to: Optional[str], print("サーバ上の機密はそのまま残っています (devbase は消しません):") print(f" 接続先: {server.url}") for unit, _ in plan.moves: - print(f" {unit.server_ref.label():<24} {server.display_path(unit.server_ref)}") + print(f" {server_store.display_label(unit.server_ref):<24} " + f"{server.display_path(unit.server_ref)}") plan.print_left_on_server() return 0 @@ -702,7 +703,7 @@ def prepare(self) -> None: def _heading(self, unit: _MoveUnit) -> str: """参照の見出し。グループ別の置き場ではサーバ上のパスを添える (値は出さない)""" - label = f"{unit.server_ref.label():<24}" + label = f"{self.server_store.display_label(unit.server_ref):<24}" if self._grouped: label += f" {self.server.display_path(unit.server_ref)}" return label diff --git a/lib/devbase/env/secret_store.py b/lib/devbase/env/secret_store.py index d0627a76..0b5dbd22 100644 --- a/lib/devbase/env/secret_store.py +++ b/lib/devbase/env/secret_store.py @@ -148,12 +148,23 @@ def for_project(name: str, *, owner: str = OWNER_TEAM, def is_user(self) -> bool: return self.owner == OWNER_USER - def label(self) -> str: + def label(self, *, group_display: Optional[str] = None) -> str: + """参照の表示。``group_display`` は括弧の中に入れる名前だけを差し替える。 + + 既定 (``None``) は ``self.group``、つまり読み替える**前**の名前である + (PLAN64 決定 2)。読み替えの解決は ``BackendConfigError`` を送出しうるため、 + 誤りを伝える文言・警告・ログは引数なしで呼び、解決を背負わない。読み替えの + 前後 (``default → nyle``) を出す見出しは + :meth:`SecretStore.display_label` を通る。 + """ # チーム単位の文字列は変えない。誤りの伝達や桁揃えに埋め込まれており、 # 変えると既存の表示とテストが一斉に動く。 base = 'グローバル' if self.kind == 'global' else f"プロジェクト '{self.name}'" text = f'個人の{base}' if self.is_user else base - return f'{text}(グループ {self.group})' if self.group else text + if not self.group: + return text + display = self.group if group_display is None else group_display + return f'{text}(グループ {display})' class SecretBackend(Protocol): @@ -457,6 +468,23 @@ def storage_group(self, group: Optional[str]) -> Optional[str]: return None return settings.storage_group(group) + def display_label(self, ref: SecretRef) -> str: + """見出しに出す参照の表示。読み替えがあればグループ名を前と後で出す (PLAN64)。 + + 見出し用の表示を作る唯一の口である (決定 3)。読み替えの要否は + :meth:`storage_group` が決める。backend の種類・設定の有無・``layout`` ・ + グループの有無をまとめて見る判定はこれだけで、``config.openbao`` が ``None`` か + どうかでは決まらない (``backend: age`` に ``openbao:`` 節が残っていれば + ``None`` にならない)。 + + 読み替えが無ければ :meth:`SecretRef.label` と同じ文字列を返すため、 + ``version: 1`` とファイル backend の出力は 1 文字も変わらない。 + """ + storage = self.storage_group(ref.group) + if storage is None or storage == ref.group: + return ref.label() + return ref.label(group_display=self.config.openbao.display_group(ref.group)) + def same_storage_group(self, a: Optional[str], b: Optional[str]) -> bool: """2 つのグループが同じ置き場へ写るか (``storage_group`` 同士の比較)。 diff --git a/tests/commands/test_env_group_label.py b/tests/commands/test_env_group_label.py new file mode 100644 index 00000000..5ff2f117 --- /dev/null +++ b/tests/commands/test_env_group_label.py @@ -0,0 +1,244 @@ +"""読み替えのあるグループでの見出しの表示 (PLAN64 / #188) + +`group_aliases` のある置き場では、見出しのグループ名が読み替えの前と後 +(`default → nyle`) になり、隣に並ぶパスと同じグループを指していると読める。 +読み替えの対応が無いグループ・`version: 1` ・ファイル backend の出力は変わらない。 +""" + +from __future__ import annotations + +import pytest + +from devbase.commands import env as env_cmd +from devbase.commands import env_backend +from devbase.env import backend_config as bc + + +ALIASES = {'default': 'nyle'} +BOTH = 'default → nyle' + + +def at(monkeypatch, root, rel=''): + monkeypatch.setenv('PWD', str(root / rel) if rel else str(root)) + + +@pytest.fixture +def aliased(openbao_root, openbao): + """``version: 2`` で ``default`` を ``nyle`` へ読み替える置き場 (``web`` は宣言なし)""" + from tests.conftest import configure_openbao + + configure_openbao(openbao_root, openbao, layout=bc.LAYOUT_GROUP, group_aliases=ALIASES) + return openbao_root + + +# --------------------------------------------------------------------------- +# 受け入れ条件 1: env backend test +# --------------------------------------------------------------------------- + +def test_backend_test_headings_show_both_names_next_to_the_path(aliased, openbao, monkeypatch, + capsys): + openbao.put('team/nyle/global', {'A': '1'}) + openbao.put('users/member01/nyle/global', {'B': '2'}) + at(monkeypatch, aliased, 'projects/web') + + assert env_backend.cmd_env_backend_test(aliased) == 0 + + lines = capsys.readouterr().out.splitlines() + for label, path in ((f'グローバル(グループ {BOTH})', 'devbase/team/nyle/global'), + (f'個人のグローバル(グループ {BOTH})', + 'devbase/users/member01/nyle/global'), + (f"プロジェクト 'web'(グループ {BOTH})", + 'devbase/team/nyle/projects/web')): + # 見出しとパスが同じ行に並び、同じグループを指していることを見る (#188) + row = [line for line in lines if line.startswith(f' {label} ')] + assert len(row) == 1, label + assert path in row[0] + + +def test_backend_test_only_reads_the_current_group_and_labels_skipped_projects( + aliased, openbao, monkeypatch, capsys): + """現状固定: with の参照を表示し、別の置き場の api は読み替え名で案内する。""" + (aliased / 'projects' / 'api').mkdir() + (aliased / 'projects' / 'web' / 'env').write_text('DEVBASE_ACCOUNT_GROUP=with\n') + openbao.put('team/nyle/global', {'A': '1'}) + openbao.put('team/nyle/projects/api', {'B': '2'}) + openbao.put('users/member01/nyle/projects/api', {'C': '3'}) + openbao.put('team/with/global', {'D': '4'}) + openbao.put('users/member01/with/global', {'E': '5'}) + openbao.put('team/with/projects/web', {'F': '6'}) + openbao.put('users/member01/with/projects/web', {'G': '7'}) + at(monkeypatch, aliased, 'projects/web') + + assert env_backend.cmd_env_backend_test(aliased) == 0 + + out = capsys.readouterr().out + skipped, read = out.split('読めた参照:', 1) + skipped_rows = [line for line in skipped.splitlines() if '調べていません' in line] + assert len(skipped_rows) == 1 + assert 'api' in skipped_rows[0] + assert 'nyle' in skipped_rows[0] + assert 'api' not in read + for label, path in ( + ('グローバル(グループ with)', 'devbase/team/with/global'), + ('個人のグローバル(グループ with)', 'devbase/users/member01/with/global'), + ("プロジェクト 'web'(グループ with)", 'devbase/team/with/projects/web'), + ("個人のプロジェクト 'web'(グループ with)", + 'devbase/users/member01/with/projects/web')): + rows = [line for line in read.splitlines() if line.startswith(f' {label} ')] + assert len(rows) == 1, label + assert path in rows[0] + + +# --------------------------------------------------------------------------- +# 受け入れ条件 2: env list +# --------------------------------------------------------------------------- + +def test_list_headings_and_counts_show_both_names(aliased, openbao, monkeypatch, capsys): + openbao.put('team/nyle/global', {'A': '1'}) + openbao.put('users/member01/nyle/global', {'B': '2'}) + openbao.put('team/nyle/projects/web', {'C': '3'}) + openbao.put('users/member01/nyle/projects/web', {'D': '4'}) + at(monkeypatch, aliased, 'projects/web') + + assert env_cmd.cmd_env_list(aliased, keys_only=True) == 0 + + out = capsys.readouterr().out + for heading in (f'=== グローバル(グループ {BOTH}) (', + f'=== 個人のグローバル(グループ {BOTH}) (', + f'=== プロジェクト: web(グループ {BOTH}) (', + f'=== 個人のプロジェクト: web(グループ {BOTH}) ('): + assert heading in out + for count in (f'グローバル(グループ {BOTH}): 1変数', + f'個人のグローバル(グループ {BOTH}): 1変数', + f'プロジェクト(グループ {BOTH}): 1変数', + f'個人のプロジェクト(グループ {BOTH}): 1変数'): + assert count in out + + +# --------------------------------------------------------------------------- +# 受け入れ条件 3: env backend migrate の 2 つの一覧 (決定 4) +# --------------------------------------------------------------------------- + +def test_migration_plan_listing_shows_both_names(aliased, openbao, capsys): + """移行の計画の一覧 (``_MigrationPlan._heading``)""" + openbao.put('team/nyle/global', {'A': '1'}) + + assert env_backend.cmd_env_backend_migrate(aliased, to='age', dry_run=True) == 0 + + out = capsys.readouterr().out + assert f'グローバル(グループ {BOTH})' in out + assert 'devbase/team/nyle/global' in out + + +def test_migration_plan_listing_shows_both_names_for_project(aliased, openbao, capsys): + """現状固定: 移行計画の一覧にプロジェクトの機密の見出しとサーバ上のパスを出す。""" + openbao.put('team/nyle/projects/web', {'B': '2'}) + + assert env_backend.cmd_env_backend_migrate(aliased, to='age', dry_run=True) == 0 + + out = capsys.readouterr().out + assert f"プロジェクト 'web'(グループ {BOTH})" in out + assert 'devbase/team/nyle/projects/web' in out + + +@pytest.fixture +def aliased_age(aliased): + """``aliased`` と同じ置き場のまま backend を age にし、age 側に機密を置く + + ``tests/commands/test_env_backend_migrate.py`` の ``grouped_age_root`` と同じ構成。 + """ + from devbase.env.secret_store import SecretRef, SecretStore + + store = SecretStore(aliased, config=bc.BackendConfig()) + store.age.save(SecretRef.for_global(), {'A': 'a-value'}) + store.age.save(SecretRef.for_project('web'), {'W': 'w-value'}) + bc.save(aliased, bc.BackendConfig(backend='age', openbao=bc.load(aliased).openbao, + version=2)) + return aliased + + +def test_migration_plan_listing_to_openbao_shows_both_names_next_to_the_path( + aliased_age, openbao, capsys): + """現状固定: 逆向き (age → openbao) の一覧も読み替えの前と後と移行先のパスを並べる。""" + assert env_backend.cmd_env_backend_migrate(aliased_age, to='openbao', assume_yes=True, + dry_run=True) == 0 + + out = capsys.readouterr().out + for label, path in ((f'グローバル(グループ {BOTH})', 'devbase/team/nyle/global'), + (f"プロジェクト 'web'(グループ {BOTH})", + 'devbase/team/nyle/projects/web')): + # 見出しと移行先のパスが同じ行に並ぶ (桁揃えの空白は見ない) + rows = [line for line in out.splitlines() if line.startswith(f' {label}')] + assert len(rows) == 1, label + assert path in rows[0] + # --dry-run なのでサーバへは書かず、機密の値も出さない + assert not any(r.kv_path for r in openbao.requests_of('POST')) + assert 'a-value' not in out and 'w-value' not in out + + +def test_completion_listing_after_migrating_to_age_shows_both_names(aliased, openbao, capsys): + """``--to age`` の完了後の「サーバ上の機密はそのまま残っています」の一覧""" + openbao.put('team/nyle/global', {'A': '1'}) + + assert env_backend.cmd_env_backend_migrate(aliased, to='age', assume_yes=True) == 0 + + out = capsys.readouterr().out + tail = out[out.index('サーバ上の機密はそのまま残っています'):] + assert f'グローバル(グループ {BOTH})' in tail + assert 'devbase/team/nyle/global' in tail + + +# --------------------------------------------------------------------------- +# 受け入れ条件 4: 読み替えの対応が無いグループ +# --------------------------------------------------------------------------- + +def test_a_group_without_an_alias_keeps_its_name(aliased, openbao, monkeypatch, capsys): + (aliased / 'projects' / 'web' / 'env').write_text('DEVBASE_ACCOUNT_GROUP=kkg\n') + openbao.put('team/kkg/global', {'A': '1'}) + at(monkeypatch, aliased, 'projects/web') + + assert env_cmd.cmd_env_list(aliased, keys_only=True) == 0 + assert env_backend.cmd_env_backend_test(aliased) == 0 + + out = capsys.readouterr().out + assert '(グループ kkg)' in out + assert '→' not in out + + +# --------------------------------------------------------------------------- +# 受け入れ条件 5・6: 変わらないこと +# --------------------------------------------------------------------------- + +def test_version_one_shows_no_group_at_all(openbao_root, openbao, monkeypatch, capsys): + """``version: 1`` (``layout: flat``) の見出しにはグループが付かない""" + openbao.put('team/global', {'A': '1'}) + at(monkeypatch, openbao_root, 'projects/web') + + assert env_cmd.cmd_env_list(openbao_root, keys_only=True) == 0 + assert env_backend.cmd_env_backend_test(openbao_root) == 0 + + out = capsys.readouterr().out + assert '=== グローバル (' in out + assert '(グループ' not in out + + +def test_a_file_backend_with_a_leftover_openbao_section_shows_no_group(openbao_root, openbao, + monkeypatch, capsys): + """決定 3: ``backend: age`` に ``openbao:`` 節が残っていてもグループは出ない + + ``config.openbao`` はこの設定では ``None`` にならない。backend の種類を + ``config.openbao is None`` で判定すると、この端末をグループ別の置き場として扱う。 + """ + from tests.conftest import configure_openbao + + settings = configure_openbao(openbao_root, openbao, layout=bc.LAYOUT_GROUP, + group_aliases=ALIASES).openbao + bc.save(openbao_root, bc.BackendConfig(backend='age', openbao=settings, version=2)) + assert bc.load(openbao_root).openbao is not None + at(monkeypatch, openbao_root, 'projects/web') + + assert env_cmd.cmd_env_list(openbao_root, keys_only=True) == 0 + + out = capsys.readouterr().out + assert '=== グローバル (' in out + assert '(グループ' not in out diff --git a/tests/env/test_secret_store_label.py b/tests/env/test_secret_store_label.py new file mode 100644 index 00000000..52925a04 --- /dev/null +++ b/tests/env/test_secret_store_label.py @@ -0,0 +1,168 @@ +"""参照の表示の契約と、見出し用の表示の口 (PLAN64) + +`SecretRef.label()` の既定は読み替える**前**のグループ名のままで、読み替えの前後 +(`default → nyle`) を出すのは `SecretStore.display_label` を通った見出しだけである +(設計の決定 1・2・3)。 +""" + +from __future__ import annotations + +import logging + +import pytest + +from devbase.env import backend_config as bc +from devbase.env.secret_store import SecretRef, SecretStore, SecretStoreError + + +ALIASES = {'default': 'nyle'} + + +def _settings(*, layout: str, aliases=None) -> bc.OpenBaoSettings: + return bc.OpenBaoSettings(url='http://127.0.0.1:8200', user='member01', layout=layout, + group_aliases=dict(aliases or {})) + + +def _store(tmp_path, *, backend: str, layout: str, aliases=None) -> SecretStore: + """設定を直接渡した店 (``secrets/backend.yml`` を読まない)""" + version = 2 if layout == bc.LAYOUT_GROUP else 1 + config = bc.BackendConfig(backend=backend, openbao=_settings(layout=layout, aliases=aliases), + version=version) + return SecretStore(tmp_path, config=config) + + +# --------------------------------------------------------------------------- +# SecretRef.label の契約 +# --------------------------------------------------------------------------- + +def test_label_without_arguments_keeps_the_group_before_the_alias(): + """決定 2: 引数なしの既定は読み替える前の名前""" + assert SecretRef.for_global(group='default').label() == 'グローバル(グループ default)' + + +@pytest.mark.parametrize('ref, expected', [ + (SecretRef.for_global(group='default'), 'グローバル(グループ default → nyle)'), + (SecretRef.for_global(owner='user', group='default'), + '個人のグローバル(グループ default → nyle)'), + (SecretRef.for_project('web', group='default'), + "プロジェクト 'web'(グループ default → nyle)"), + (SecretRef.for_project('web', owner='user', group='default'), + "個人のプロジェクト 'web'(グループ default → nyle)"), +]) +def test_label_puts_group_display_inside_the_parentheses(ref, expected): + """決定 1: 括弧の中に入れる名前だけを外から受け、文言は label が持つ""" + assert ref.label(group_display='default → nyle') == expected + + +@pytest.mark.parametrize('ref', [ + SecretRef.for_global(), + SecretRef.for_global(owner='user'), + SecretRef.for_project('web'), +]) +def test_label_ignores_group_display_when_the_reference_has_no_group(ref): + assert ref.label(group_display='default → nyle') == ref.label() + assert '(グループ' not in ref.label(group_display='default → nyle') + + +# --------------------------------------------------------------------------- +# SecretStore.display_label の分岐 +# --------------------------------------------------------------------------- + +def test_display_label_shows_both_names_when_the_group_is_aliased(tmp_path): + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + + assert (store.display_label(SecretRef.for_global(group='default')) + == 'グローバル(グループ default → nyle)') + + +def test_display_label_shows_both_names_for_project_references(tmp_path): + """現状固定: プロジェクト参照および個人プロジェクト参照でも読み替え後のグループ名を表示する。""" + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + + assert (store.display_label(SecretRef.for_project('web', group='default')) + == "プロジェクト 'web'(グループ default → nyle)") + assert (store.display_label(SecretRef.for_project('web', owner='user', group='default')) + == "個人のプロジェクト 'web'(グループ default → nyle)") + + +def test_display_label_keeps_the_name_when_the_group_has_no_alias(tmp_path): + """受け入れ条件 4: 対応が無いグループでは ``→`` を付けない""" + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + ref = SecretRef.for_global(group='kkg') + + assert store.display_label(ref) == ref.label() == 'グローバル(グループ kkg)' + + +def test_display_label_of_a_reference_without_a_group_is_the_plain_label(tmp_path): + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + + assert store.display_label(SecretRef.for_global()) == 'グローバル' + + +def test_display_label_ignores_a_leftover_openbao_section_on_a_file_backend(tmp_path): + """決定 3: backend の種類を ``config.openbao is None`` で判定してはならない + + ``backend: age`` に ``openbao:`` 節が残っていても ``config.openbao`` は ``None`` に + ならない。判定は ``storage_group`` (backend の種類・設定の有無・layout をまとめて見る) + に任せるため、グループの付いた参照を渡しても読み替えない。 + """ + store = _store(tmp_path, backend='age', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + + assert store.display_label(SecretRef.for_global(group='default')) \ + == 'グローバル(グループ default)' + + +def test_display_label_does_not_map_on_a_flat_layout(tmp_path): + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_FLAT) + + assert store.display_label(SecretRef.for_global()) == 'グローバル' + + +def test_display_label_keeps_a_grouped_reference_on_a_flat_layout(tmp_path): + """現状固定: flat の表示は参照に付いたグループをそのまま残す。""" + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_FLAT) + + assert (store.display_label(SecretRef.for_global(group='default')) + == 'グローバル(グループ default)') + + +# --------------------------------------------------------------------------- +# 受け入れ条件 7: 誤りを伝える文言は読み替えない +# --------------------------------------------------------------------------- + +def test_the_flat_layout_refusal_names_the_group_before_the_alias(tmp_path): + """``OpenBaoBackend._check_group`` の文言は引数なしの ``label()`` を通す + + ``layout: flat`` に ``group_aliases`` を置いた設定は ``validate()`` が拒むが、ここで + 見るのは文言が読み替えの解決を背負わないことである (決定 2)。``label()`` の既定を + 読み替え後にすると、この文言が ``default → nyle`` になる。 + """ + from devbase.env.openbao import OpenBaoBackend + + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_FLAT, aliases=ALIASES) + backend = OpenBaoBackend(store) + + with pytest.raises(SecretStoreError) as excinfo: + backend.path_of(SecretRef.for_global(group='default')) + + assert '(グループ default)' in str(excinfo.value) + assert '→' not in str(excinfo.value) + + +def test_the_account_group_warning_names_the_group_before_the_alias(openbao_root, openbao, + caplog): + """置き場の ``DEVBASE_ACCOUNT_GROUP`` を使わない旨の警告も読み替えない (PLAN62)""" + from devbase.env import keys, runtime + from tests.conftest import configure_openbao + + configure_openbao(openbao_root, openbao, layout=bc.LAYOUT_GROUP, group_aliases=ALIASES) + openbao.put('team/nyle/global', {keys.DEVBASE_ACCOUNT_GROUP: 'kkg', 'A': '1'}) + + with caplog.at_level(logging.WARNING): + runtime.resolve(openbao_root, None) + + messages = [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING and keys.DEVBASE_ACCOUNT_GROUP in r.getMessage()] + assert len(messages) == 1 + assert '機密の置き場(グローバル(グループ default))' in messages[0] + assert '→' not in messages[0]