- docs/commands.md 新增 §3.8 jwks、§3.9 accounts、§3.10 audit-logs 規格 - Api 新增 jwks/accounts/audit-logs 端點(共用 api_request) - 新增 Jwks/Accounts/AuditLogs/Admin 模組;CLI 接線三個指令群 - 403 → 退出碼 8 提示需 admin;一次性密碼只在成功當下輸出 - 測試 100 例全綠(fake API 注入,比照 cli_test.exs)
527 lines
15 KiB
Elixir
527 lines
15 KiB
Elixir
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
|