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 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