feat: 實作 bear CLI PAT 模式 MVP(login/whoami/token/logout/status)
- 新增 Elixir + Mix escript 專案(Req 0.5+、Jason) - login --token <PAT>:以 PAT 驗證 /userinfo 後寫入憑證檔(0600) - whoami:GET /userinfo 顯示身分 claims(支援 --json) - token:印出 access token 供 pipe(--refresh 在 PAT 模式忽略) - logout:清除本機憑證;status:純本機判定 - 憑證檔採原子寫入(rename)、0600、O_CREAT|O_EXCL 防 symlink - 全域選項:--issuer/--config/--json/-v/-h/--version - 29 個單元測試;mix precommit(compile --warnings-as-errors + format + test) - 更新 README.md 與 docs/commands.md 反映 PAT 模式 MVP Device Flow 登入待伺服器端 P1(bear 倉庫)完成後再接。issue #2
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
defmodule BearCli.CLITest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BearCli.CLI
|
||||
|
||||
setup do
|
||||
old_creds = System.get_env("BEAR_CREDENTIALS")
|
||||
|
||||
on_exit(fn ->
|
||||
restore_env("BEAR_CREDENTIALS", old_creds)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "parse/1" do
|
||||
test "no args -> help" do
|
||||
assert {:help, %{}} = CLI.parse([])
|
||||
end
|
||||
|
||||
test "--version" do
|
||||
assert {:version, %{}} = CLI.parse(["--version"])
|
||||
end
|
||||
|
||||
test "-h alias -> help" do
|
||||
assert {:help, %{}} = CLI.parse(["-h"])
|
||||
end
|
||||
|
||||
test "login --token X" do
|
||||
assert {:ok, :login, %{token: "X"}} = CLI.parse(["login", "--token", "X"])
|
||||
end
|
||||
|
||||
test "global option before command" do
|
||||
assert {:ok, :whoami, %{json: true}} = CLI.parse(["--json", "whoami"])
|
||||
end
|
||||
|
||||
test "global option after command" do
|
||||
assert {:ok, :status, %{json: true}} = CLI.parse(["status", "--json"])
|
||||
end
|
||||
|
||||
test "token --refresh" do
|
||||
assert {:ok, :token, %{refresh: true}} = CLI.parse(["token", "--refresh"])
|
||||
end
|
||||
|
||||
test "issuer flag is captured" do
|
||||
assert {:ok, :whoami, %{issuer: "https://x"}} =
|
||||
CLI.parse(["whoami", "--issuer", "https://x"])
|
||||
end
|
||||
|
||||
test "unknown command -> usage error" do
|
||||
assert {:error, _, 2} = CLI.parse(["frobnicate"])
|
||||
end
|
||||
|
||||
test "unknown option -> usage error" do
|
||||
assert {:error, _, 2} = CLI.parse(["--bogus"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "dispatch/2 (injected api)" do
|
||||
test "login --token writes credentials and prints email" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
System.put_env("BEAR_CREDENTIALS", path)
|
||||
|
||||
{code, out} =
|
||||
ExUnit.CaptureIO.with_io(fn ->
|
||||
CLI.dispatch(CLI.parse(["login", "--token", "good"]), api: fake_api())
|
||||
end)
|
||||
|
||||
assert code == 0
|
||||
assert out =~ "已登入:alice@example.com"
|
||||
assert {:ok, %{"access_token" => "good"}} = BearCli.Credentials.load(path)
|
||||
end
|
||||
|
||||
test "login with invalid token exits 1" do
|
||||
System.put_env("BEAR_CREDENTIALS", tmp_path())
|
||||
|
||||
{code, err} =
|
||||
ExUnit.CaptureIO.with_io(
|
||||
:stderr,
|
||||
"",
|
||||
fn -> CLI.dispatch(CLI.parse(["login", "--token", "bad"]), api: fake_api()) end
|
||||
)
|
||||
|
||||
assert code == 1
|
||||
assert err =~ "token 無效"
|
||||
end
|
||||
|
||||
test "login without --token points to Device Flow being unavailable" do
|
||||
System.put_env("BEAR_CREDENTIALS", tmp_path())
|
||||
|
||||
{code, err} =
|
||||
ExUnit.CaptureIO.with_io(
|
||||
:stderr,
|
||||
"",
|
||||
fn -> CLI.dispatch(CLI.parse(["login"]), api: fake_api()) end
|
||||
)
|
||||
|
||||
assert code == 2
|
||||
assert err =~ "Device Flow"
|
||||
end
|
||||
|
||||
test "whoami when not logged in exits 3" do
|
||||
System.put_env("BEAR_CREDENTIALS", "/nonexistent/credentials.json")
|
||||
|
||||
{code, err} =
|
||||
ExUnit.CaptureIO.with_io(
|
||||
:stderr,
|
||||
"",
|
||||
fn -> CLI.dispatch(CLI.parse(["whoami"]), api: fake_api()) end
|
||||
)
|
||||
|
||||
assert code == 3
|
||||
assert err =~ "未登入"
|
||||
end
|
||||
|
||||
test "whoami prints claims" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
System.put_env("BEAR_CREDENTIALS", path)
|
||||
BearCli.Credentials.save(%{"issuer" => "https://x", "access_token" => "good"}, path)
|
||||
|
||||
{code, out} =
|
||||
ExUnit.CaptureIO.with_io(fn ->
|
||||
CLI.dispatch(CLI.parse(["whoami"]), api: fake_api())
|
||||
end)
|
||||
|
||||
assert code == 0
|
||||
assert out =~ "email"
|
||||
assert out =~ "alice@example.com"
|
||||
end
|
||||
|
||||
test "token prints only the token" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
System.put_env("BEAR_CREDENTIALS", path)
|
||||
BearCli.Credentials.save(%{"issuer" => "https://x", "access_token" => "good-token"}, path)
|
||||
|
||||
{code, out} =
|
||||
ExUnit.CaptureIO.with_io(fn ->
|
||||
CLI.dispatch(CLI.parse(["token"]), api: fake_api())
|
||||
end)
|
||||
|
||||
assert code == 0
|
||||
assert String.trim(out) == "good-token"
|
||||
end
|
||||
|
||||
test "status when logged in exits 0 and reports PAT mode" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
System.put_env("BEAR_CREDENTIALS", path)
|
||||
|
||||
BearCli.Credentials.save(
|
||||
%{"issuer" => "https://x", "email" => "a@b.c", "access_token" => "t"},
|
||||
path
|
||||
)
|
||||
|
||||
{code, out} =
|
||||
ExUnit.CaptureIO.with_io(fn ->
|
||||
CLI.dispatch(CLI.parse(["status"]), api: fake_api())
|
||||
end)
|
||||
|
||||
assert code == 0
|
||||
assert out =~ "已登入:a@b.c"
|
||||
assert out =~ "PAT"
|
||||
end
|
||||
|
||||
test "logout clears credentials" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
System.put_env("BEAR_CREDENTIALS", path)
|
||||
BearCli.Credentials.save(%{"issuer" => "https://x", "access_token" => "t"}, path)
|
||||
|
||||
{code, _out} =
|
||||
ExUnit.CaptureIO.with_io(fn ->
|
||||
CLI.dispatch(CLI.parse(["logout"]), api: fake_api())
|
||||
end)
|
||||
|
||||
assert code == 0
|
||||
assert :error == BearCli.Credentials.load(path)
|
||||
end
|
||||
end
|
||||
|
||||
defp fake_api do
|
||||
fn _issuer, token ->
|
||||
case token do
|
||||
"good" -> {:ok, %{"sub" => "u1", "email" => "alice@example.com", "name" => "Alice"}}
|
||||
"bad" -> {:error, :unauthorized, "invalid_token"}
|
||||
_ -> {:error, :server_error, 500}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp tmp_path do
|
||||
Path.join(System.tmp_dir!(), "bear_cli_cli_#{System.unique_integer([:positive])}.json")
|
||||
end
|
||||
|
||||
defp restore_env(key, nil), do: System.delete_env(key)
|
||||
defp restore_env(key, value), do: System.put_env(key, value)
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
defmodule BearCli.ConfigTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias BearCli.Config
|
||||
|
||||
@nonexistent "/nonexistent/bear_cli_config.json"
|
||||
|
||||
setup do
|
||||
old_issuer = System.get_env("BEAR_ISSUER")
|
||||
old_config = System.get_env("BEAR_CONFIG")
|
||||
|
||||
on_exit(fn ->
|
||||
restore_env("BEAR_ISSUER", old_issuer)
|
||||
restore_env("BEAR_CONFIG", old_config)
|
||||
end)
|
||||
|
||||
System.delete_env("BEAR_ISSUER")
|
||||
System.put_env("BEAR_CONFIG", @nonexistent)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "defaults to the built-in issuer when nothing else is set" do
|
||||
assert Config.resolve_issuer(nil) == "https://alterminal.com"
|
||||
end
|
||||
|
||||
test "CLI flag takes precedence over everything" do
|
||||
System.put_env("BEAR_ISSUER", "https://env.example.com")
|
||||
assert Config.resolve_issuer("https://flag.example.com") == "https://flag.example.com"
|
||||
end
|
||||
|
||||
test "BEAR_ISSUER env wins over config and default" do
|
||||
System.put_env("BEAR_ISSUER", "https://env.example.com")
|
||||
assert Config.resolve_issuer(nil) == "https://env.example.com"
|
||||
end
|
||||
|
||||
test "config file issuer wins over default" do
|
||||
path = write_config(%{"issuer" => "https://config.example.com"})
|
||||
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
|
||||
assert Config.resolve_issuer(nil, path) == "https://config.example.com"
|
||||
end
|
||||
|
||||
test "config path override is honored" do
|
||||
path = write_config(%{"issuer" => "https://override.example.com"})
|
||||
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
|
||||
assert Config.resolve_issuer(nil, path) == "https://override.example.com"
|
||||
end
|
||||
|
||||
defp write_config(map) do
|
||||
path =
|
||||
Path.join(System.tmp_dir!(), "bear_cli_config_#{System.unique_integer([:positive])}.json")
|
||||
|
||||
File.write!(path, Jason.encode!(map))
|
||||
path
|
||||
end
|
||||
|
||||
defp restore_env(key, nil), do: System.delete_env(key)
|
||||
defp restore_env(key, value), do: System.put_env(key, value)
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
defmodule BearCli.CredentialsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias BearCli.Credentials
|
||||
|
||||
test "save/load round-trips JSON" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
|
||||
assert :ok ==
|
||||
Credentials.save(
|
||||
%{"issuer" => "x", "access_token" => "tok", "email" => "a@b.c"},
|
||||
path
|
||||
)
|
||||
|
||||
assert {:ok, %{"access_token" => "tok", "email" => "a@b.c"}} = Credentials.load(path)
|
||||
end
|
||||
|
||||
test "save creates the file with 0600 permissions" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
:ok = Credentials.save(%{"access_token" => "tok"}, path)
|
||||
|
||||
mode = File.stat!(path).mode
|
||||
assert Bitwise.band(mode, 0o777) == 0o600
|
||||
end
|
||||
|
||||
test "save creates the parent directory" do
|
||||
base = Path.join(System.tmp_dir!(), "bear_cli_nested_#{System.unique_integer([:positive])}")
|
||||
path = Path.join([base, "sub", "credentials.json"])
|
||||
on_exit(fn -> File.rm_rf(base) end)
|
||||
|
||||
:ok = Credentials.save(%{"a" => "b"}, path)
|
||||
assert File.regular?(path)
|
||||
end
|
||||
|
||||
test "load returns :error for a missing file" do
|
||||
assert :error == Credentials.load("/nonexistent/credentials.json")
|
||||
end
|
||||
|
||||
test "load returns :error for corrupt JSON" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
File.write!(path, "{not json")
|
||||
|
||||
assert :error == Credentials.load(path)
|
||||
end
|
||||
|
||||
test "delete removes the file and is idempotent" do
|
||||
path = tmp_path()
|
||||
on_exit(fn -> File.rm(path) end)
|
||||
:ok = Credentials.save(%{"a" => "b"}, path)
|
||||
|
||||
assert :ok == Credentials.delete(path)
|
||||
assert :error == Credentials.load(path)
|
||||
assert :ok == Credentials.delete(path)
|
||||
end
|
||||
|
||||
defp tmp_path do
|
||||
Path.join(System.tmp_dir!(), "bear_cli_creds_#{System.unique_integer([:positive])}.json")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user