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
This commit is contained in:
2026-09-10 03:15:53 +08:00
parent e23be309f8
commit d70aa9e541
9 changed files with 1720 additions and 2 deletions
+526
View File
@@ -0,0 +1,526 @@
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