Files
bear-cli/test/bear_cli/admin_commands_test.exs
T
queena d70aa9e541 feat: 實作管理端指令群 jwks/accounts/audit-logs(issue #12)
- 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);基於含 PR #15 的最新 main
2026-09-10 03:15:53 +08:00

527 lines
15 KiB
Elixir
Raw 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.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