feat: 實作 bear apps 指令群 list/show/create/update/rotate-secret/toggle(issue #10)

- Api:新增 /api/v1/apps 端點(apps_list/get/create/update/rotate_secret/toggle,
  401/403/404/422/5xx/網路 分流)
- Apps:指令群實作——client_id → UUID 以 list 解析;一次性 client_secret
  僅於 create/rotate-secret 成功當下輸出;--json 輸出符合 docs/commands.md §3.6;
  visibility/status 過濾由 CLI 本地套用
- CLI:apps 子指令解析(重複選項收集為清單)、本地用法驗證(缺參數/列舉值/
  PKCE 未綁 --jwk-id → 退出碼 2)、help 更新
- 測試:注入 fake API 的單元測試 29 例(解析、輸出、退出碼、UUID 解析、
  401/403/404/422 分流、PKCE rotate)
- 文件:README.md 實作狀態、docs/commands.md §3.6/§9 更新
This commit is contained in:
2026-09-08 20:58:54 +08:00
parent 77c65ec299
commit 2192d067ce
6 changed files with 1255 additions and 5 deletions
+517
View File
@@ -0,0 +1,517 @@
defmodule BearCli.AppsTest do
@moduledoc """
`bear apps` 指令群單元測試(issue #10):注入 fake API,涵蓋解析、
輸出、退出碼、client_id → UUID 解析與 401/403/404/422 分流。
"""
use ExUnit.Case, async: false
alias BearCli.{Apps, CLI}
# -- fake API --
defmodule FakeApi do
@apps [
%{
"id" => "0192aaaa-0000-7000-8000-000000000001",
"client_id" => "my-app",
"title" => "My App",
"url" => "https://example.com",
"method" => "client_secret",
"status" => "active",
"visibility" => "public",
"scopes" => ["openid", "profile"],
"redirect_urls" => ["https://example.com/callback"],
"post_logout_redirect_uris" => [],
"sub" => "id",
"jwk_id" => nil,
"created_at" => "2026-09-07T00:00:00Z",
"updated_at" => "2026-09-07T00:00:00Z"
},
%{
"id" => "0192aaaa-0000-7000-8000-000000000002",
"client_id" => "internal-tool",
"title" => "Internal Tool",
"url" => "https://internal.example.com",
"method" => "PKCE",
"status" => "inactive",
"visibility" => "internal",
"scopes" => ["openid"],
"redirect_urls" => [],
"post_logout_redirect_uris" => [],
"sub" => "id",
"jwk_id" => "jwk-1",
"created_at" => "2026-09-06T00:00:00Z",
"updated_at" => "2026-09-06T00:00:00Z"
}
]
def apps, do: @apps
def apps_list(_issuer, _token, params) do
if error = Process.get(:fake_api_error) do
error
else
page = params[:page] || 1
per_page = params[:per_page] || 20
{:ok,
%{
"data" => Enum.slice(@apps, (page - 1) * per_page, per_page),
"page" => page,
"per_page" => per_page,
"total" => length(@apps)
}}
end
end
def apps_get(_issuer, _token, id) do
if error = Process.get(:fake_api_error) do
error
else
case Enum.find(@apps, &(&1["id"] == id)) do
nil -> {:error, :not_found, "not_found"}
app -> {:ok, %{"data" => app}}
end
end
end
def apps_create(_issuer, _token, attrs) do
if error = Process.get(:fake_api_error) do
error
else
app =
%{
"id" => "0192aaaa-0000-7000-8000-000000000003",
"client_id" => attrs["client_id"],
"title" => attrs["title"],
"url" => attrs["url"],
"method" => attrs["method"] || "client_secret",
"status" => "active",
"visibility" => attrs["visibility"] || "internal",
"scopes" => attrs["scopes"] || ["openid"],
"redirect_urls" => attrs["redirect_urls"] || [],
"post_logout_redirect_uris" => attrs["post_logout_redirect_uris"] || [],
"sub" => attrs["sub"] || "id",
"jwk_id" => attrs["jwk_id"],
"created_at" => "2026-09-08T00:00:00Z",
"updated_at" => "2026-09-08T00:00:00Z"
}
{:ok, %{"data" => app, "client_secret" => "4f9c1d2e-new-secret"}}
end
end
def apps_update(_issuer, _token, id, _attrs) do
if error = Process.get(:fake_api_error) do
error
else
case Enum.find(@apps, &(&1["id"] == id)) do
nil -> {:error, :not_found, "not_found"}
app -> {:ok, %{"data" => app}}
end
end
end
def apps_rotate_secret(_issuer, _token, id) do
if error = Process.get(:fake_api_error) do
error
else
case Enum.find(@apps, &(&1["id"] == id)) do
%{"method" => "PKCE"} -> {:error, :unprocessable_entity, %{"error" => "pkce_app"}}
%{} -> {:ok, %{"client_secret" => "9a7b3c-rotated"}}
nil -> {:error, :not_found, "not_found"}
end
end
end
def apps_toggle(_issuer, _token, id) do
if error = Process.get(:fake_api_error) do
error
else
case Enum.find(@apps, &(&1["id"] == id)) do
nil ->
{:error, :not_found, "not_found"}
app ->
new_status = if app["status"] == "active", do: "inactive", else: "active"
{:ok, %{"data" => %{app | "status" => new_status}}}
end
end
end
end
# -- 測試輔助 --
setup do
old_token = System.get_env("BEAR_TOKEN")
old_creds = System.get_env("BEAR_CREDENTIALS")
on_exit(fn ->
restore(old_token, "BEAR_TOKEN")
restore(old_creds, "BEAR_CREDENTIALS")
end)
System.put_env("BEAR_TOKEN", "admin-pat")
System.put_env("BEAR_CREDENTIALS", "/nonexistent/credentials.json")
:ok
end
# 成功路徑:捕獲 stdout,回傳 {退出碼, stdout}
defp run_out(argv) do
ExUnit.CaptureIO.with_io(fn ->
CLI.dispatch(CLI.parse(argv), apps_api: FakeApi)
end)
end
# 錯誤路徑:捕獲 stderr,回傳 {退出碼, stderr}
defp run_err(argv) do
ExUnit.CaptureIO.with_io(:stderr, "", fn ->
CLI.dispatch(CLI.parse(argv), apps_api: FakeApi)
end)
end
defp with_api_error(error) do
Process.put(:fake_api_error, error)
end
# -- list --
test "apps list renders table sorted by client_id" do
{code, out} = run_out(["apps", "list"])
assert code == 0
assert out =~ "CLIENT ID"
assert out =~ "internal-tool"
assert out =~ "my-app"
# 依 client_id 排序:internal-tool 在 my-app 之前
assert String.contains?(out, "internal-tool")
assert :binary.match(out, "internal-tool") < :binary.match(out, "my-app")
end
test "apps list --json outputs spec shape" do
{code, out} = run_out(["apps", "list", "--json"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["ok"] == true
assert decoded["page"] == 1
assert decoded["per_page"] == 20
assert decoded["total"] == 2
assert Enum.any?(decoded["apps"], &(&1["client_id"] == "my-app"))
assert Enum.any?(decoded["apps"], &(&1["client_id"] == "internal-tool"))
end
test "apps list --visibility/--status filters locally" do
{code, out} = run_out(["apps", "list", "--visibility", "public", "--status", "active"])
assert code == 0
assert out =~ "my-app"
refute out =~ "internal-tool"
end
test "apps list server-side pagination (per-page 1, page 2)" do
# 無過濾 → 直接交給 API 分頁;fake API 第 2 頁(per_page=1)回傳第二筆
{code, out} = run_out(["apps", "list", "--per-page", "1", "--page", "2"])
assert code == 0
assert out =~ "internal-tool"
refute out =~ "my-app"
end
test "apps list 403 exits 8 with admin hint" do
with_api_error({:error, :forbidden, "Admin role required."})
{code, err} = run_err(["apps", "list"])
assert code == 8
assert err =~ "admin"
end
# -- show --
test "apps show <client-id> resolves UUID and prints fields" do
{code, out} = run_out(["apps", "show", "my-app"])
assert code == 0
assert out =~ "client_id"
assert out =~ "my-app"
assert out =~ "https://example.com"
# secret 值不會出現(method 欄位的 "client_secret" 是合法輸出)
refute out =~ "4f9c1d2e"
refute out =~ "9a7b3c"
end
test "apps show --json" do
{code, out} = run_out(["apps", "show", "my-app", "--json"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["ok"] == true
assert decoded["app"]["client_id"] == "my-app"
assert decoded["app"]["id"] == "0192aaaa-0000-7000-8000-000000000001"
end
test "apps show unknown client_id exits 1" do
{code, err} = run_err(["apps", "show", "no-such-app"])
assert code == 1
assert err =~ "找不到"
end
test "apps show without client-id exits 2" do
{code, err} = run_err(["apps", "show"])
assert code == 2
assert err =~ "缺少"
end
# -- create --
test "apps create sends required attrs and prints one-time secret" do
{code, out} =
run_out([
"apps",
"create",
"--client-id",
"new-app",
"--url",
"https://new.example.com",
"--title",
"New App",
"--visibility",
"public",
"--scope",
"openid",
"--scope",
"profile"
])
assert code == 0
assert out =~ "App 已建立:new-app"
assert out =~ "只顯示這一次"
assert out =~ "4f9c1d2e-new-secret"
end
test "apps create --json includes client_secret once" do
{code, out} =
run_out([
"apps",
"create",
"--client-id",
"new-app",
"--url",
"https://new.example.com",
"--title",
"New App",
"--json"
])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["ok"] == true
assert decoded["client_secret"] == "4f9c1d2e-new-secret"
end
test "apps create missing required exits 2" do
{code, err} = run_err(["apps", "create", "--client-id", "x"])
assert code == 2
assert err =~ "缺少必選參數"
assert err =~ "url"
assert err =~ "title"
end
test "apps create invalid visibility exits 2" do
{code, err} =
run_err([
"apps",
"create",
"--client-id",
"x",
"--url",
"https://x",
"--title",
"X",
"--visibility",
"bogus"
])
assert code == 2
assert err =~ "--visibility"
end
test "apps create PKCE without jwk-id exits 2" do
{code, err} =
run_err([
"apps",
"create",
"--client-id",
"x",
"--url",
"https://x",
"--title",
"X",
"--method",
"PKCE"
])
assert code == 2
assert err =~ "--jwk-id"
end
test "apps create 422 renders field errors and exits 1" do
with_api_error(
{:error, :unprocessable_entity, %{"errors" => %{"client_id" => ["has already been taken"]}}}
)
{code, err} =
run_err([
"apps",
"create",
"--client-id",
"my-app",
"--url",
"https://x",
"--title",
"X"
])
assert code == 1
assert err =~ "422"
assert err =~ "client_id"
end
# -- update --
test "apps update sends only given fields" do
{code, out} = run_out(["apps", "update", "my-app", "--title", "Renamed"])
assert code == 0
assert out =~ "client_id"
assert out =~ "my-app"
end
test "apps update --json" do
{code, out} = run_out(["apps", "update", "my-app", "--title", "Renamed", "--json"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["ok"] == true
assert decoded["app"]["client_id"] == "my-app"
end
# -- rotate-secret --
test "apps rotate-secret prints one-time new secret" do
{code, out} = run_out(["apps", "rotate-secret", "my-app"])
assert code == 0
assert out =~ "已輪轉"
assert out =~ "9a7b3c-rotated"
end
test "apps rotate-secret on PKCE app exits 1" do
{code, err} = run_err(["apps", "rotate-secret", "internal-tool"])
assert code == 1
assert err =~ "PKCE"
end
# -- toggle --
test "apps toggle prints transition" do
{code, out} = run_out(["apps", "toggle", "my-app"])
assert code == 0
assert out =~ "my-app:active → inactive"
end
test "apps toggle --json returns new status" do
{code, out} = run_out(["apps", "toggle", "my-app", "--json"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["ok"] == true
assert decoded["app"]["status"] == "inactive"
end
# -- 認證分流 --
test "apps list 401 exits 3" do
with_api_error({:error, :unauthorized, "Invalid or expired token"})
{code, err} = run_err(["apps", "list"])
assert code == 3
assert err =~ "bear login"
end
test "apps list network error exits 6" do
with_api_error({:error, :network, "connection refused"})
{code, err} = run_err(["apps", "list"])
assert code == 6
assert err =~ "無法連線"
end
test "not logged in exits 3 without credentials" do
System.delete_env("BEAR_TOKEN")
{code, err} = run_err(["apps", "list"])
assert code == 3
assert err =~ "未登入"
end
# -- 解析 --
test "apps without verb exits 2" do
assert {:error, msg, 2} = CLI.parse(["apps"])
assert msg =~ "子指令"
end
test "apps unknown verb exits 2" do
assert {:error, msg, 2} = CLI.parse(["apps", "frobnicate"])
assert msg =~ "未知的 apps 子指令"
end
test "apps list parses spec options" do
assert {:ok, {Apps, :list}, opts} =
CLI.parse([
"apps",
"list",
"--visibility",
"public",
"--page",
"2",
"--per-page",
"5"
])
assert opts[:visibility] == "public"
assert opts[:page] == 2
assert opts[:per_page] == 5
end
test "apps create parses repeatable list options" do
assert {:ok, {Apps, :create}, opts} =
CLI.parse([
"apps",
"create",
"--client-id",
"x",
"--url",
"https://x",
"--title",
"X",
"--redirect-url",
"https://a/cb",
"--redirect-url",
"https://b/cb",
"--scope",
"openid",
"--scope",
"email",
"--post-logout-redirect-uri",
"https://a/logout"
])
assert opts[:redirect_url] == ["https://a/cb", "https://b/cb"]
assert opts[:scope] == ["openid", "email"]
assert opts[:post_logout_redirect_uri] == ["https://a/logout"]
end
test "apps show keeps positional client-id" do
assert {:ok, {Apps, :show}, opts} = CLI.parse(["apps", "show", "my-app", "--json"])
assert opts[:client_id_arg] == "my-app"
assert opts[:json] == true
end
# -- 輔助 --
defp restore(nil, key), do: System.delete_env(key)
defp restore(value, key), do: System.put_env(key, value)
end