merge: rebase 到最新 main(吸納 PR #15 個人自助指令群)後新增管理端指令群

- main 已合併 PR #15(profile/password/email/sessions/tokens/mfa 與 eqrcode 依賴)
- 保留 main 的 self_service 模組與 api.ex 個人自助端點,於其上重套管理端:
  jwks/accounts/audit-logs 指令、API 端點、CLI 接線、docs §3.8–§3.10、README 表格
- mix precommit 全綠(150 tests:admin 100 + self-service 50)
This commit is contained in:
2026-09-10 03:15:11 +08:00
parent 464db21745
commit 9784e18c24
8 changed files with 1888 additions and 50 deletions
+662
View File
@@ -0,0 +1,662 @@
defmodule BearCli.SelfServiceTest do
@moduledoc """
`bear` 個人自助指令群單元測試(issue #11):注入 fake API,涵蓋
解析、輸出、退出碼、互動輸入注入與 401/403/404/422/429 分流。
"""
use ExUnit.Case, async: false
alias BearCli.{CLI, SelfService}
# -- fake API --
defmodule FakeApi do
@profile %{
"id" => "0190aaaa-0000-7000-8000-000000000001",
"email" => "alice@example.com",
"role" => "user",
"name" => "Alice Chen",
"given_name" => "Alice",
"family_name" => "Chen",
"nickname" => "ali",
"preferred_username" => "alice",
"locale" => "zh_TW",
"zoneinfo" => "Asia/Taipei",
"phone_number" => nil,
"phone_number_verified" => false,
"address" => nil,
"mfa_enabled" => false,
"created_at" => "2026-09-01T00:00:00Z",
"updated_at" => "2026-09-01T00:00:00Z"
}
@sessions [
%{
"id" => "0192a1b0-0000-7000-8000-0000000000aa",
"ip" => "203.0.113.10",
"user_agent" => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
"created_at" => "2026-09-07T10:00:00Z",
"last_seen_at" => "2026-09-08T09:00:00Z"
}
]
@tokens [
%{
"id" => "0192a1c0-0000-7000-8000-0000000000bb",
"name" => "ci-token",
"scope" => "openid profile email",
"expires_at" => "2999-01-01T00:00:00Z",
"revoked_at" => nil,
"last_used_at" => "2026-09-08T08:00:00Z",
"created_at" => "2026-09-01T00:00:00Z"
}
]
def error do
Process.get(:fake_ss_error)
end
def profile_get(_issuer, _token) do
error() || {:ok, %{"data" => profile()}}
end
def profile do
Map.merge(@profile, Process.get(:fake_profile_overrides) || %{})
end
def profile_update(_issuer, _token, attrs) do
error() || {:ok, %{"data" => Map.merge(profile(), attrs)}}
end
def profile_change_password(_issuer, _token, current, new) do
cond do
error() ->
error()
current == "wrong-pass" ->
{:error, :unprocessable_entity,
%{"errors" => %{"current_password" => ["is incorrect"]}}}
byte_size(new) < 8 ->
{:error, :unprocessable_entity,
%{"errors" => %{"new_password" => ["must be at least 8 characters"]}}}
true ->
{:ok, %{"data" => %{"updated" => true}}}
end
end
def profile_email_request_codes(_issuer, _token, new_email) do
cond do
error() ->
error()
new_email == "alice@example.com" ->
{:error, :unprocessable_entity,
%{"error" => "same_email", "error_description" => "The new email must be different."}}
true ->
{:ok, %{"data" => %{"sent" => true}}}
end
end
def profile_email_change(_issuer, _token, _new_email, current_code, _new_code) do
cond do
error() ->
error()
current_code == "000000" ->
{:error, :unprocessable_entity,
%{"error" => "invalid_current_code", "error_description" => "incorrect"}}
true ->
{:ok, %{"data" => %{"email" => "new@example.com"}}}
end
end
def sessions_list(_issuer, _token) do
error() || {:ok, %{"data" => @sessions}}
end
def sessions_revoke(_issuer, _token, id) do
cond do
error() ->
error()
id == "missing" ->
{:error, :not_found, "not_found"}
true ->
{:ok, %{"data" => %{"revoked" => id}}}
end
end
def sessions_revoke_others(_issuer, _token) do
error() || {:ok, %{"data" => %{"revoked_count" => 3}}}
end
def tokens_list(_issuer, _token) do
error() || {:ok, %{"data" => @tokens}}
end
def tokens_create(_issuer, _token, name, expires_in) do
cond do
error() ->
error()
name == " " ->
{:error, :unprocessable_entity, %{"errors" => %{"name" => ["can't be blank"]}}}
true ->
expires_at = if expires_in == "never", do: nil, else: "2999-01-01T00:00:00Z"
{:ok,
%{
"data" => %{
"id" => "0192a1c0-0000-7000-8000-0000000000cc",
"name" => name,
"scope" => "openid profile email",
"expires_at" => expires_at,
"revoked_at" => nil,
"last_used_at" => nil,
"created_at" => "2026-09-09T00:00:00Z"
},
"token" => "9f3a-raw-token-once"
}}
end
end
def tokens_revoke(_issuer, _token, id) do
cond do
error() ->
error()
id == "missing" ->
{:error, :not_found, "not_found"}
true ->
{:ok, %{"data" => %{"revoked" => id}}}
end
end
def mfa_setup(_issuer, _token) do
error() ||
{:ok,
%{"data" => %{"secret" => "JBSWY3DPEHPK3PXP", "otpauth_uri" => "otpauth://totp/Bear:x"}}}
end
def mfa_setup_confirm(_issuer, _token, code) do
cond do
error() ->
error()
code == "000000" ->
{:error, :unprocessable_entity,
%{"error" => "invalid_code", "error_description" => "Invalid verification code."}}
true ->
{:ok, %{"data" => %{"enabled" => true, "recovery_codes" => ["rc-1", "rc-2"]}}}
end
end
def mfa_disable(_issuer, _token, code) do
cond do
error() ->
error()
code == "000000" ->
{:error, :unprocessable_entity,
%{
"error" => "invalid_code",
"error_description" => "Invalid code. MFA was not disabled."
}}
true ->
{:ok, %{"data" => %{"enabled" => false}}}
end
end
def mfa_recovery_codes(_issuer, _token, code) do
cond do
error() ->
error()
code == "000000" ->
{:error, :unprocessable_entity,
%{"error" => "invalid_code", "error_description" => "Invalid code."}}
true ->
{:ok, %{"data" => %{"recovery_codes" => ["new-rc-1", "new-rc-2"]}}}
end
end
end
# 互動輸入注入:依序回應佇列中的值;prompt 前綴不影響。
defmodule FakeIO do
def prompt_secret(_label) do
case Process.get(:fake_io_inputs) do
[] ->
:eof
[next | rest] ->
Process.put(:fake_io_inputs, rest)
{:ok, next}
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", "user-pat")
System.put_env("BEAR_CREDENTIALS", "/nonexistent/credentials.json")
:ok
end
defp run_out(argv, io_inputs \\ nil) do
if io_inputs, do: Process.put(:fake_io_inputs, io_inputs)
opts = [ss_api: FakeApi, io: FakeIO, tty?: true]
ExUnit.CaptureIO.with_io(fn ->
CLI.dispatch(CLI.parse(argv), opts)
end)
end
defp run_err(argv, io_inputs \\ nil) do
if io_inputs, do: Process.put(:fake_io_inputs, io_inputs)
opts = [ss_api: FakeApi, io: FakeIO, tty?: true]
ExUnit.CaptureIO.with_io(:stderr, "", fn ->
CLI.dispatch(CLI.parse(argv), opts)
end)
end
defp with_api_error(error) do
Process.put(:fake_ss_error, error)
end
defp restore(nil, key), do: System.delete_env(key)
defp restore(value, key), do: System.put_env(key, value)
# -- 解析:用法錯誤(退出碼 2)--
test "unknown self-service command exits 2" do
assert {:error, _msg, 2} = CLI.parse(["frobnicate"])
end
test "profile without verb exits 2" do
assert {:error, msg, 2} = CLI.parse(["profile"])
assert msg =~ "profile 缺少子指令"
end
test "profile unknown verb exits 2" do
assert {:error, msg, 2} = CLI.parse(["profile", "delete"])
assert msg =~ "未知的 profile 子指令"
end
test "profile show with positional exits 2" do
assert {:error, msg, 2} = CLI.parse(["profile", "show", "extra"])
assert msg =~ "不接受位置參數"
end
test "password unknown verb exits 2" do
assert {:error, msg, 2} = CLI.parse(["password", "reset"])
assert msg =~ "未知的 password 子指令"
end
test "password change rejects positional args" do
assert {:error, msg, 2} = CLI.parse(["password", "change", "secret"])
assert msg =~ "不接受參數"
end
test "email change missing --new exits 2 at runtime" do
{code, err} = run_err(["email", "change"])
assert code == 2
assert err =~ "缺少 --new"
end
test "sessions revoke missing id exits 2" do
assert {:error, msg, 2} = CLI.parse(["sessions", "revoke"])
assert msg =~ "缺少 <id>"
end
test "tokens create missing --name exits 2" do
assert {:error, msg, 2} = CLI.parse(["tokens", "create"])
assert msg =~ "缺少 --name"
end
test "tokens create invalid --expires-in exits 2" do
{code, err} = run_err(["tokens", "create", "--name", "x", "--expires-in", "-5"])
assert code == 2
assert err =~ "--expires-in"
end
test "mfa unknown verb exits 2" do
assert {:error, msg, 2} = CLI.parse(["mfa", "enable"])
assert msg =~ "未知的 mfa 子指令"
end
# -- profile --
test "profile show prints fields" do
{code, out} = run_out(["profile", "show"])
assert code == 0
assert out =~ "email"
assert out =~ "alice@example.com"
assert out =~ "mfa_enabled"
end
test "profile show --json outputs spec shape" do
{code, out} = run_out(["profile", "show", "--json"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["ok"] == true
assert decoded["profile"]["email"] == "alice@example.com"
end
test "profile set sends only given fields" do
{code, out} = run_out(["profile", "set", "--name", "New Name", "--locale", "zh_TW"])
assert code == 0
assert out =~ "New Name"
end
test "profile set without fields exits 2" do
{code, err} = run_err(["profile", "set"])
assert code == 2
assert err =~ "未給任何欄位"
end
test "profile set invalid gender exits 2 without request" do
{code, err} = run_err(["profile", "set", "--gender", "robot"])
assert code == 2
assert err =~ "--gender"
end
test "profile set invalid url exits 2" do
{code, err} = run_err(["profile", "set", "--picture", "ftp://x"])
assert code == 2
assert err =~ "http(s)://"
end
# -- password --
test "password change happy path" do
{code, out} = run_out(["password", "change"], ["old-pass", "new-pass-9", "new-pass-9"])
assert code == 0
assert out =~ "密碼已變更"
end
test "password change mismatched confirmation exits 2 locally" do
{code, err} = run_err(["password", "change"], ["old", "new-pass-9", "different-9"])
assert code == 2
assert err =~ "不一致"
end
test "password change wrong current password maps to 422 exit 1" do
{code, err} = run_err(["password", "change"], ["wrong-pass", "new-pass-9", "new-pass-9"])
assert code == 1
assert err =~ "current_password"
end
test "password change in non-TTY exits 2" do
{code, _out} =
ExUnit.CaptureIO.with_io(fn ->
CLI.dispatch(
CLI.parse(["password", "change"]),
ss_api: FakeApi,
tty?: false
)
end)
assert code == 2
end
# -- email --
test "email change full flow" do
{code, out} =
run_out(["email", "change", "--new", "new@example.com"], ["111111", "222222"])
assert code == 0
assert out =~ "email 已更新:new@example.com"
end
test "email change same email maps 422 error to exit 1" do
{code, err} =
run_err(["email", "change", "--new", "alice@example.com"], ["111111", "222222"])
assert code == 1
assert err =~ "same_email"
end
test "email change invalid code maps to exit 1" do
{code, err} =
run_err(["email", "change", "--new", "new@example.com"], ["000000", "222222"])
assert code == 1
assert err =~ "invalid_current_code"
end
test "email change rate limited maps 429 to exit 1" do
with_api_error({:error, :too_many_requests, %{"error" => "rate_limited"}})
{code, err} = run_err(["email", "change", "--new", "new@example.com"], [])
assert code == 1
assert err =~ "429"
end
# -- sessions --
test "sessions list renders table" do
{code, out} = run_out(["sessions", "list"])
assert code == 0
assert out =~ "ID"
assert out =~ "203.0.113.10"
assert out =~ "Mozilla/5.0"
end
test "sessions list --json outputs spec shape" do
{code, out} = run_out(["sessions", "list", "--json"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["ok"] == true
assert length(decoded["sessions"]) == 1
assert hd(decoded["sessions"])["user_agent"] =~ "Mozilla"
end
test "sessions revoke prints confirmation" do
{code, out} = run_out(["sessions", "revoke", "0192a1b0-0000"])
assert code == 0
assert out =~ "session 已撤銷"
end
test "sessions revoke missing id maps 404 to exit 1" do
{code, err} = run_err(["sessions", "revoke", "missing"])
assert code == 1
assert err =~ "404"
end
test "sessions revoke-others prints count" do
{code, out} = run_out(["sessions", "revoke-others"])
assert code == 0
assert out =~ "已撤銷 3 個其他 session"
end
# -- tokens --
test "tokens list renders table with status" do
{code, out} = run_out(["tokens", "list"])
assert code == 0
assert out =~ "ci-token"
assert out =~ "active"
end
test "tokens list --json outputs spec shape" do
{code, out} = run_out(["tokens", "list", "--json"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["ok"] == true
assert hd(decoded["tokens"])["name"] == "ci-token"
# 明文絕不出現在 list
refute out =~ "9f3a-raw-token-once"
end
test "tokens create shows raw token exactly once" do
{code, out} = run_out(["tokens", "create", "--name", "ci-token"])
assert code == 0
assert out =~ "PAT 已建立:ci-token"
assert out =~ "9f3a-raw-token-once"
assert out =~ "只顯示這一次"
end
test "tokens create --json includes raw once" do
{code, out} = run_out(["tokens", "create", "--name", "ci", "--json"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["raw"] == "9f3a-raw-token-once"
end
test "tokens create never expiry" do
{code, out} = run_out(["tokens", "create", "--name", "ci", "--expires-in", "never"])
assert code == 0
assert out =~ "無到期日"
end
test "tokens revoke prints confirmation" do
{code, out} = run_out(["tokens", "revoke", "0192a1c0-0000"])
assert code == 0
assert out =~ "PAT 已撤銷"
end
# -- mfa --
test "mfa status shows disabled" do
{code, out} = run_out(["mfa", "status"])
assert code == 0
assert out =~ "未啟用"
end
test "mfa status shows enabled when totp on" do
Process.put(:fake_profile_overrides, %{"mfa_enabled" => true})
{code, out} = run_out(["mfa", "status"])
assert code == 0
assert out =~ "已啟用"
after
Process.delete(:fake_profile_overrides)
end
test "mfa setup prints QR + secret then recovery codes" do
{code, out} = run_out(["mfa", "setup"], ["123456"])
assert code == 0
assert out =~ "otpauth://totp/Bear:x"
assert out =~ "JBSWY3DPEHPK3PXP"
# QR 為 ASCII 區塊字元
assert out =~ "█"
assert out =~ "recovery codes"
assert out =~ "rc-1"
end
test "mfa setup --json does not print QR block" do
{code, out} = run_out(["mfa", "setup", "--json"], ["123456"])
assert code == 0
assert {:ok, decoded} = Jason.decode(out)
assert decoded["recovery_codes"] == ["rc-1", "rc-2"]
end
test "mfa setup invalid code maps to exit 1" do
{code, err} = run_err(["mfa", "setup"], ["000000"])
assert code == 1
assert err =~ "invalid_code"
end
test "mfa setup already enabled maps 422 to exit 1" do
with_api_error(
{:error, :unprocessable_entity,
%{"error" => "already_enabled", "error_description" => "MFA is already enabled."}}
)
{code, err} = run_err(["mfa", "setup"], ["123456"])
assert code == 1
assert err =~ "already_enabled"
end
test "mfa disable happy path" do
{code, out} = run_out(["mfa", "disable"], ["123456"])
assert code == 0
assert out =~ "MFA 已停用"
end
test "mfa recovery-codes regenerates" do
{code, out} = run_out(["mfa", "recovery-codes"], ["123456"])
assert code == 0
assert out =~ "new-rc-1"
assert out =~ "只顯示這一次"
end
# -- 共通錯誤分流 --
test "401 maps to exit 3 with re-login hint" do
with_api_error({:error, :unauthorized, "invalid_token"})
{code, err} = run_err(["profile", "show"])
assert code == 3
assert err =~ "bear login"
end
test "403 maps to exit 8" do
with_api_error({:error, :forbidden, "forbidden"})
{code, err} = run_err(["profile", "show"])
assert code == 8
assert err =~ "403"
end
test "network error maps to exit 6" do
with_api_error({:error, :network, "econnrefused"})
{code, err} = run_err(["profile", "show"])
assert code == 6
assert err =~ "econnrefused"
end
test "server error maps to exit 6" do
with_api_error({:error, :server_error, 500})
{code, err} = run_err(["profile", "show"])
assert code == 6
assert err =~ "500"
end
test "not logged in exits 3" do
System.delete_env("BEAR_TOKEN")
{code, err} = run_err(["profile", "show"])
assert code == 3
assert err =~ "未登入"
end
# -- 直接模組層測試 --
test "SelfService.run/3 with injected api" do
{code, out} =
ExUnit.CaptureIO.with_io(fn ->
SelfService.run(:profile, :show,
ss_api: FakeApi,
issuer: "https://alterminal.com"
)
end)
assert code == 0
assert out =~ "alice@example.com"
end
end