Files
bear-cli/test/bear_cli/self_service_test.exs
T
queena 9784e18c24 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)
2026-09-10 03:15:11 +08:00

663 lines
18 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.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