defmodule BearCli.AdminCommandsTest do @moduledoc """ `bear jwks`/`bear accounts`/`bear audit-logs` 管理端指令群單元測試(issue #12): 注入 fake API,涵蓋解析、輸出、退出碼與 401/403/404/422 分流。 """ use ExUnit.Case, async: false alias BearCli.{Accounts, AuditLogs, CLI, Jwks} # -- fake API -- defmodule FakeApi do @jwks [ %{ "id" => "0192cccc-0000-7000-8000-000000000001", "kid" => "sig-2026-01", "kty" => "RSA", "alg" => "RS256", "use" => "sig", "active" => true, "created_at" => "2026-09-09T00:00:00Z" }, %{ "id" => "0192cccc-0000-7000-8000-000000000002", "kid" => "sig-2025-12", "kty" => "EC", "alg" => "ES256", "use" => "sig", "active" => false, "created_at" => "2025-12-01T00:00:00Z" } ] @accounts [ %{ "id" => "0192bbbb-0000-7000-8000-000000000001", "email" => "admin@example.com", "role" => "admin", "totp_enabled" => true, "created_at" => "2026-01-01T00:00:00Z", "updated_at" => "2026-01-01T00:00:00Z" }, %{ "id" => "0192bbbb-0000-7000-8000-000000000002", "email" => "alice@example.com", "role" => "user", "totp_enabled" => false, "created_at" => "2026-02-01T00:00:00Z", "updated_at" => "2026-02-01T00:00:00Z" } ] @audit_entries [ %{ "id" => "0192dddd-0000-7000-8000-000000000001", "event" => "account_created_by_admin", "category" => "account", "account_id" => "0192bbbb-0000-7000-8000-000000000002", "actor_id" => "0192bbbb-0000-7000-8000-000000000001", "ip" => "127.0.0.1", "metadata" => %{"via" => "api"}, "created_at" => "2026-09-09T01:00:00Z" } ] @emails %{ "0192bbbb-0000-7000-8000-000000000001" => "admin@example.com", "0192bbbb-0000-7000-8000-000000000002" => "alice@example.com" } def jwks_list(_issuer, _token), do: ok_or_error(%{"data" => @jwks}) def jwks_create(_issuer, _token, attrs) do key = %{ "id" => "0192cccc-0000-7000-8000-000000000003", "kid" => attrs["kid"], "kty" => "RSA", "alg" => attrs["alg"], "use" => "sig", "active" => true, "created_at" => "2026-09-09T02:00:00Z" } ok_or_error(%{"data" => key}) end def jwks_get(_issuer, _token, id) do case Enum.find(@jwks, &(&1["id"] == id)) do nil -> {:error, :not_found, "not_found"} key -> ok_or_error(%{"data" => key}) end end def jwks_toggle(_issuer, _token, id) do case Enum.find(@jwks, &(&1["id"] == id)) do nil -> {:error, :not_found, "not_found"} key -> new_active = !key["active"] ok_or_error(%{"data" => %{key | "active" => new_active}}) end end def accounts_list(_issuer, _token, params) do page = params[:page] || 1 per_page = params[:per_page] || 20 ok_or_error(%{ "data" => Enum.slice(@accounts, (page - 1) * per_page, per_page), "page" => page, "per_page" => per_page, "total" => length(@accounts) }) end def accounts_create(_issuer, _token, attrs) do account = %{ "id" => "0192bbbb-0000-7000-8000-000000000003", "email" => attrs["email"], "role" => attrs["role"] || "user", "totp_enabled" => false, "created_at" => "2026-09-09T00:00:00Z", "updated_at" => "2026-09-09T00:00:00Z" } ok_or_error(%{"data" => account}) end def accounts_get(_issuer, _token, id) do case Enum.find(@accounts, &(&1["id"] == id)) do nil -> {:error, :not_found, "not_found"} account -> ok_or_error(%{"data" => account}) end end def accounts_update_role(_issuer, _token, id, attrs) do case Enum.find(@accounts, &(&1["id"] == id)) do nil -> {:error, :not_found, "not_found"} account -> ok_or_error(%{"data" => %{account | "role" => attrs["role"]}}) end end def accounts_set_password(_issuer, _token, id, _attrs) do case Enum.find(@accounts, &(&1["id"] == id)) do nil -> {:error, :not_found, "not_found"} _account -> ok_or_error(%{}) end end def accounts_delete(_issuer, _token, id) do case Enum.find(@accounts, &(&1["id"] == id)) do nil -> {:error, :not_found, "not_found"} _account -> ok_or_error(%{}) end end def audit_logs_list(_issuer, _token, params) do category = params[:category] entries = if category, do: Enum.filter(@audit_entries, &(&1["category"] == category)), else: @audit_entries ok_or_error(%{ "data" => entries, "page" => params[:page] || 1, "per_page" => params[:per_page] || 50, "total" => length(entries), "total_pages" => 1, "emails" => @emails }) end defp ok_or_error(body) do if error = Process.get(:fake_api_error) do error else {:ok, body} 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 defp run_out(argv) do ExUnit.CaptureIO.with_io(fn -> CLI.dispatch(CLI.parse(argv), admin_apis()) end) end defp run_err(argv) do ExUnit.CaptureIO.with_io(:stderr, "", fn -> CLI.dispatch(CLI.parse(argv), admin_apis()) end) end defp admin_apis do [jwks_api: FakeApi, accounts_api: FakeApi, audit_logs_api: FakeApi] end defp with_api_error(error) do Process.put(:fake_api_error, error) end # -- jwks -- test "jwks list renders table" do {code, out} = run_out(["jwks", "list"]) assert code == 0 assert out =~ "KID" assert out =~ "sig-2026-01" assert out =~ "sig-2025-12" assert out =~ "active" assert out =~ "inactive" end test "jwks list --json outputs spec shape" do {code, out} = run_out(["jwks", "list", "--json"]) assert code == 0 assert {:ok, decoded} = Jason.decode(out) assert decoded["ok"] == true assert Enum.any?(decoded["jwks"], &(&1["kid"] == "sig-2026-01")) # key_data 永不出現 refute out =~ "key_data" end test "jwks show resolves kid and prints public metadata" do {code, out} = run_out(["jwks", "show", "sig-2026-01"]) assert code == 0 assert out =~ "kid" assert out =~ "sig-2026-01" assert out =~ "RS256" refute out =~ "key_data" end test "jwks show unknown kid exits 1" do {code, err} = run_err(["jwks", "show", "no-such-key"]) assert code == 1 assert err =~ "找不到" end test "jwks create sends kid/alg and prints summary" do {code, out} = run_out(["jwks", "create", "--kid", "sig-2026-09", "--alg", "ES256"]) assert code == 0 assert out =~ "JWK 已建立:sig-2026-09" assert out =~ "ES256" end test "jwks create without --kid exits 2" do {code, err} = run_err(["jwks", "create"]) assert code == 2 assert err =~ "--kid" end test "jwks create invalid alg exits 2" do {code, err} = run_err(["jwks", "create", "--kid", "k", "--alg", "HS256"]) assert code == 2 assert err =~ "--alg" end test "jwks toggle prints transition" do {code, out} = run_out(["jwks", "toggle", "sig-2026-01"]) assert code == 0 assert out =~ "sig-2026-01:active → inactive" end test "jwks toggle --json returns new state" do {code, out} = run_out(["jwks", "toggle", "sig-2026-01", "--json"]) assert code == 0 assert {:ok, decoded} = Jason.decode(out) assert decoded["ok"] == true assert decoded["jwk"]["kid"] == "sig-2026-01" assert decoded["jwk"]["active"] == false end test "jwks list 403 exits 8 with admin hint" do with_api_error({:error, :forbidden, "Admin role required."}) {code, err} = run_err(["jwks", "list"]) assert code == 8 assert err =~ "admin" end test "jwks list 401 exits 3" do with_api_error({:error, :unauthorized, "invalid_token"}) {code, err} = run_err(["jwks", "list"]) assert code == 3 assert err =~ "bear login" end test "jwks unknown verb exits 2" do {code, err} = run_err(["jwks", "rotate"]) assert code == 2 assert err =~ "未知的 jwks 子指令" end # -- accounts -- test "accounts list renders table" do {code, out} = run_out(["accounts", "list"]) assert code == 0 assert out =~ "EMAIL" assert out =~ "admin@example.com" assert out =~ "alice@example.com" end test "accounts list --json outputs spec shape" do {code, out} = run_out(["accounts", "list", "--json"]) assert code == 0 assert {:ok, decoded} = Jason.decode(out) assert decoded["ok"] == true assert decoded["page"] == 1 assert decoded["total"] == 2 assert Enum.any?(decoded["accounts"], &(&1["email"] == "alice@example.com")) end test "accounts list pagination params pass through" do {code, out} = run_out(["accounts", "list", "--per-page", "1", "--page", "2"]) assert code == 0 assert out =~ "alice@example.com" refute out =~ "admin@example.com" end test "accounts show resolves email and prints fields" do {code, out} = run_out(["accounts", "show", "alice@example.com"]) assert code == 0 assert out =~ "email" assert out =~ "alice@example.com" refute out =~ "hash_password" refute out =~ "totp_secret" end test "accounts show unknown email exits 1" do {code, err} = run_err(["accounts", "show", "nobody@example.com"]) assert code == 1 assert err =~ "找不到" end test "accounts create --generate-password sends plaintext once" do {code, out} = run_out([ "accounts", "create", "--email", "new@example.com", "--generate-password", "--role", "admin" ]) assert code == 0 assert out =~ "帳號已建立:new@example.com" assert out =~ "初始密碼(只顯示這一次" end test "accounts create without email exits 2" do {code, err} = run_err(["accounts", "create", "--generate-password"]) assert code == 2 assert err =~ "--email" end test "accounts create invalid role exits 2" do {code, err} = run_err([ "accounts", "create", "--email", "x@example.com", "--role", "root", "--generate-password" ]) assert code == 2 assert err =~ "--role" end test "accounts update requires --role" do {code, err} = run_err(["accounts", "update", "alice@example.com"]) assert code == 2 assert err =~ "--role" end test "accounts update sends role and prints transition" do {code, out} = run_out(["accounts", "update", "alice@example.com", "--role", "admin"]) assert code == 0 assert out =~ "alice@example.com:角色 → admin" end test "accounts set-password non-tty without generate exits 2" do {code, err} = run_err(["accounts", "set-password", "alice@example.com"]) assert code == 2 assert err =~ "--generate-password" end test "accounts set-password --generate-password prints one-time password" do {code, out} = run_out(["accounts", "set-password", "alice@example.com", "--generate-password"]) assert code == 0 assert out =~ "只顯示這一次" end test "accounts delete prints confirmation" do {code, out} = run_out(["accounts", "delete", "alice@example.com"]) assert code == 0 assert out =~ "已刪除帳號:alice@example.com" end test "accounts delete 403 self-delete exits 8" do with_api_error({:error, :forbidden, "cannot delete yourself"}) {code, err} = run_err(["accounts", "delete", "admin@example.com"]) assert code == 8 assert err =~ "admin" end test "accounts list 403 exits 8" do with_api_error({:error, :forbidden, "Admin role required."}) {code, _err} = run_err(["accounts", "list"]) assert code == 8 end # -- audit-logs -- test "audit-logs list renders table with emails" do {code, out} = run_out(["audit-logs", "list"]) assert code == 0 assert out =~ "EVENT" assert out =~ "account_created_by_admin" assert out =~ "admin@example.com" assert out =~ "alice@example.com" end test "audit-logs list --json outputs spec shape" do {code, out} = run_out(["audit-logs", "list", "--json"]) assert code == 0 assert {:ok, decoded} = Jason.decode(out) assert decoded["ok"] == true assert decoded["total"] == 1 assert decoded["total_pages"] == 1 assert decoded["emails"]["0192bbbb-0000-7000-8000-000000000002"] == "alice@example.com" assert Enum.any?(decoded["entries"], &(&1["event"] == "account_created_by_admin")) end test "audit-logs list --category filters server-side" do {code, out} = run_out(["audit-logs", "list", "--category", "account"]) assert code == 0 assert out =~ "account_created_by_admin" end test "audit-logs list --category jwk filters to empty" do {code, out} = run_out(["audit-logs", "list", "--category", "jwk"]) assert code == 0 refute out =~ "account_created_by_admin" end test "audit-logs list 403 exits 8" do with_api_error({:error, :forbidden, "Admin role required."}) {code, err} = run_err(["audit-logs", "list"]) assert code == 8 assert err =~ "admin" end test "audit-logs list network error exits 6" do with_api_error({:error, :network, "econnrefused"}) {code, err} = run_err(["audit-logs", "list"]) assert code == 6 assert err =~ "econnrefused" end # -- 解析 -- test "parse returns group module and verb" do assert {:ok, {Jwks, :list}, opts} = CLI.parse(["jwks", "list", "--json"]) assert opts[:json] == true assert {:ok, {Accounts, :create}, opts2} = CLI.parse(["accounts", "create", "--email", "x@example.com", "--generate-password"]) assert opts2[:email] == "x@example.com" assert opts2[:generate_password] == true assert {:ok, {AuditLogs, :list}, opts3} = CLI.parse(["audit-logs", "list", "--page", "2"]) assert opts3[:page] == 2 end test "unknown group verb reports in group error" do assert {:error, msg, 2} = CLI.parse(["accounts", "purge"]) assert msg =~ "未知的 accounts 子指令" end test "accounts update keeps positional email" do assert {:ok, {Accounts, :update}, opts} = CLI.parse(["accounts", "update", "a@example.com", "--role", "admin"]) assert opts[:email_arg] == "a@example.com" assert opts[:role] == "admin" end # -- 輔助 -- defp restore(nil, key), do: System.delete_env(key) defp restore(value, key), do: System.put_env(key, value) end