Files
alex 2192d067ce 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 更新
2026-09-08 20:58:54 +08:00

518 lines
14 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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