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:
@@ -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
|
||||
Reference in New Issue
Block a user