- 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 更新
518 lines
14 KiB
Elixir
518 lines
14 KiB
Elixir
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
|