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 =~ "缺少 " 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