feat: 實作 vault 指令群 status/unlock/lock/list/get/create/edit/delete/restore/purge/folders/sync/password/rescue(issue #17)

- 對接 alterminal/bear#41 的 /api/v1/vault JSON API(PAT Bearer)
- 客戶端端到端加密,格式與 Web Vault(P2)完全一致:
  - 加密字串 2.<iv>.<ct>.<mac>(AES-256-CBC+HMAC-SHA-256、PKCS#7、encrypt-then-MAC)
  - 主金鑰 PBKDF2-SHA512(迭代數取自 /vault/config 與 profile,不寫死;salt=email 小寫)
  - BIP39 助記詞(12 字、128-bit、英文詞表)+救援路徑
- K_user 僅存記憶體:vault unlock 匯出 BEAR_VAULT_SESSION(base64),
  同 Bitwarden CLI BW_SESSION 慣例;lock 提示 unset;不寫入任何檔案
- docs/commands.md 補 §3.11 規格;README 同步
- 測試:fake API 注入+Bear.Vault.Crypto 密文樣本交叉驗證(雙向);
  BIP39 官方向量;46 個新測試,全套 196 passed
This commit is contained in:
2026-09-10 23:27:47 +08:00
parent a880bcea4d
commit 0b18d76cfd
9 changed files with 2488 additions and 2 deletions
+712
View File
@@ -0,0 +1,712 @@
defmodule BearCli.VaultTest do
@moduledoc """
`bear vault` 指令群單元測試(issue #17):注入 fake API 與 fake IO,
涵蓋解析、退出碼、session 傳遞、輸出,以及以 bear 倉庫
Bear.Vault.Crypto 產生的密文樣本做交叉驗證(解密方向)。
"""
use ExUnit.Case, async: false
alias BearCli.{CLI, Vault}
alias BearCli.Vault.{BIP39, Crypto}
# -- 測試向量(bear 倉庫 Bear.Vault.Crypto 產生;低迭代數 1000)--
@email "alice@example.com"
@master_password "hunter2-master"
@iterations 1000
@mnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
@k_user_b64 "K/O8bAn/PY6dn/eDgrk+nygzsJqefb8X6dXJbsObc52vgwE59gJneAoisgLUVH27ZJzo0ZmZ9YHINm77GDBhvQ=="
# Bear.Vault.Crypto 產生的密文樣本(Bear 加密 → CLI 必須能解)
@bear_fields %{
"name" =>
"2.d6+BTOM7a9cfjkwHuVwMiQ==.HjD0PvHOAvF3paiwPVYhEw==.PP1LIagMuk+K1abbVsacUuZ75D6encFDxqoPlpYdxX0=",
"username" =>
"2.vC7LtEADotrjHGCsjW5Uig==.IaNd0TABb1ho8A4G0pqA/2n0Na6cxQ0ZwoN4bKt2R6A=.x8QsmI0yJCcLEZV4gsCKAFHQeG1knlr/djc18oAGBJA=",
"password" =>
"2.nxg73aQIDN4xvpEMBk3dSQ==.Y3+Tb5FwJkG/MmdtcTu1K82yiyQHFFqZeqR/IhJaOq0=.8jixeUyAfq+pw/wHuywg63VCIOCqGx3MJDnPV33NozU=",
"notes" =>
"2.v5pif7yQpe2u3XAgH06Srw==.oDT6EqIuzuVcZca2oI4j2AJWQk70XmLWn0C9GY/NkMs=.ntfUvhrh8W879tdRM/VVBaRKJ9fwTvZBOKybZXK+1Ww=",
"uri" =>
"2.Dip98+nZdQa73RVoYxRXmg==.9IVFM9cxG6qLfdcvpPNNPnukpasXXVC3qnYaPQeBp+U=.5AdqSZcYL3vIxAOOkQPCszIwtgGbmTwMuk5rraJuCD4="
}
@wrapped_password "2.7QpF/MDK1QxjxvHuW+InIw==.Ner6k/lJdta6eBfFICfBXpSeUTuOoABGKUp8dG9joah1dWrjazOJqW/9YPGgiAgYS25wif2WgtnQ5grXHY2xrjSvRclb0rdH1mSA/ublaVfVxKMFGZPnkeoQgB+FmjcP.iRDkb7+J67pVh/ywWiWP+bkZut2k4o7JrJfJG6izZzg="
@wrapped_mnemonic "2.i73ZfZChxC+x8qIWtXRQ5w==.5gOWELJjoYm78kP6cXvUG1yR4X6T44c7sTHMp0om8RhljFa9dfGLCXaxTULLBHlJ/9EbrRj3Q3kUaG3/e6grAm37L4zEJZsXcETQS9J4tvHSh87lqGH+3lvMAsBlRpVp.ePbxk+weahIIaZdQlCRqHQ0KTnSinUOleuoUIMghlqE="
@folder_name_ct "2.uREiwRhNrOwjocwcGJrSXA==.vc5kZtTN/SfDJB0/3Qe5yg==.fVSBlhTtmpP0Lm8rbHT+n1ftomf3chV8pWwpK0Rwgig="
@extra_ct "2.W1q2N0MclfjZvF0puu+g0A==.K3s0aX2La3XwsUu9cIwluQ==./ZrMME1LJoHjtev/xWXRwcPmIEiyN/Nq4QcauPiRWSw="
@profile %{
"id" => "0193aaaa-0000-7000-8000-0000000000aa",
"security_stamp" => "stamp-1",
"kdf" => %{
"iterations" => @iterations,
"hash" => "SHA-512",
"salt" => "account_email_lowercase"
},
"wrapped_user_password" => @wrapped_password,
"wrapped_user_mnemonic" => @wrapped_mnemonic,
"enabled" => true,
"last_synced_at" => "2026-09-10T00:00:00Z"
}
@folder %{
"id" => "0193bbbb-0000-7000-8000-0000000000bb",
"folder_uuid" => "folder-uuid-1",
"name" => @folder_name_ct,
"revision_date" => "2026-09-10T01:00:00Z"
}
@cipher %{
"id" => "0193cccc-0000-7000-8000-0000000000cc",
"cipher_uuid" => "cipher-uuid-1",
"cipher_type" => "login",
"name" => @bear_fields["name"],
"username" => @bear_fields["username"],
"password" => @bear_fields["password"],
"uris" => [@bear_fields["uri"]],
"notes" => @bear_fields["notes"],
"data" => %{"folder_uuid" => "folder-uuid-1", "extra" => @extra_ct},
"favorite" => false,
"reprompt" => 0,
"deleted_at" => nil,
"revision_date" => "2026-09-10T05:00:00Z"
}
# -- fake API --
defmodule FakeApi do
def error, do: Process.get(:fake_vault_error)
def error(result), do: Process.put(:fake_vault_error, result)
def uploads, do: Process.get(:fake_vault_uploads, [])
def push_upload(u), do: Process.put(:fake_vault_uploads, [u | uploads()])
def vault_config(_issuer, _token) do
error() ||
{:ok,
%{
"data" => %{
"kdf" => %{
"iterations" => 1000,
"hash" => "SHA-512",
"salt" => "account_email_lowercase"
},
"encryption_types" => ["2"]
}
}}
end
def vault_profile_get(_issuer, _token) do
error() || {:ok, %{"data" => profile()}}
end
def vault_profile_put(_issuer, _token, attrs) do
push_upload({:profile, attrs})
{:ok, %{"data" => Map.merge(profile(), attrs)}}
end
def vault_sync(_issuer, _token) do
error() ||
{:ok,
%{
"data" => %{
"profile" => profile(),
"folders" => [folder()],
"ciphers" => [cipher()],
"domains" => nil
}
}}
end
def vault_ciphers_list(_issuer, _token) do
error() || {:ok, %{"data" => [cipher()]}}
end
def vault_ciphers_upsert(_issuer, _token, attrs) do
push_upload({:cipher_upsert, attrs})
{:ok, %{"data" => Map.merge(cipher(), attrs)}}
end
def vault_ciphers_get(_issuer, _token, "cipher-uuid-1") do
error() || {:ok, %{"data" => cipher()}}
end
def vault_ciphers_get(_issuer, _token, _other) do
error() || {:error, :not_found, "not_found"}
end
def vault_ciphers_update(_issuer, _token, uuid, attrs) do
push_upload({:cipher_update, uuid, attrs})
{:ok, %{"data" => Map.merge(cipher(), attrs)}}
end
def vault_ciphers_delete(_issuer, _token, uuid) do
push_upload({:cipher_delete, uuid})
{:ok, %{"data" => cipher()}}
end
def vault_ciphers_restore(_issuer, _token, uuid) do
push_upload({:cipher_restore, uuid})
{:ok, %{"data" => cipher()}}
end
def vault_ciphers_purge(_issuer, _token, uuid) do
push_upload({:cipher_purge, uuid})
{:ok, %{"data" => %{"deleted" => uuid}}}
end
def vault_folders_list(_issuer, _token) do
error() || {:ok, %{"data" => [folder()]}}
end
def vault_folders_upsert(_issuer, _token, attrs) do
push_upload({:folder_upsert, attrs})
{:ok, %{"data" => Map.merge(folder(), attrs)}}
end
def vault_folders_rename(_issuer, _token, uuid, attrs) do
push_upload({:folder_rename, uuid, attrs})
{:ok, %{"data" => Map.merge(folder(), attrs)}}
end
def vault_folders_delete(_issuer, _token, uuid) do
push_upload({:folder_delete, uuid})
{:ok, %{"data" => %{"deleted" => uuid}}}
end
defp profile, do: BearCli.VaultTest.profile_fixture()
defp folder, do: BearCli.VaultTest.folder_fixture()
defp cipher, do: BearCli.VaultTest.cipher_fixture()
end
# -- fake IO(prompt_secret/1 不回顯;prompt/1 一般輸入)--
defmodule FakeIO do
defp take(queue_key) do
case Process.get(queue_key) do
[] ->
:eof
[next | rest] ->
Process.put(queue_key, rest)
{:ok, next}
end
end
def prompt_secret(_label), do: take(:fake_io_inputs)
def prompt(_label), do: take(:fake_io_inputs)
end
# -- 測試輔助 --
setup do
old = System.get_env("BEAR_TOKEN")
old_session = System.get_env("BEAR_VAULT_SESSION")
old_creds = System.get_env("BEAR_CREDENTIALS")
on_exit(fn ->
restore(old, "BEAR_TOKEN")
restore(old_session, "BEAR_VAULT_SESSION")
restore(old_creds, "BEAR_CREDENTIALS")
end)
System.put_env("BEAR_TOKEN", "user-pat")
System.put_env("BEAR_CREDENTIALS", "/nonexistent/credentials.json")
System.delete_env("BEAR_VAULT_SESSION")
:ok
end
def profile_fixture, do: @profile
def folder_fixture, do: @folder
def cipher_fixture, do: @cipher
defp run_out(argv, inputs \\ [], session \\ nil) do
Process.put(:fake_io_inputs, inputs)
Process.put(:fake_vault_uploads, [])
FakeApi.error(nil)
opts = [vault_api: FakeApi, io: FakeIO, tty?: true, email: @email] |> maybe_session(session)
ExUnit.CaptureIO.with_io(fn ->
CLI.dispatch(CLI.parse(argv), opts)
end)
end
# 錯誤輸出在 stderr;以 with_io(:stderr) 捕捉。不重設 FakeApi.error
# (呼叫者可先設定錯誤情境)。
defp run_err(argv, inputs \\ [], session \\ nil) do
Process.put(:fake_io_inputs, inputs)
Process.put(:fake_vault_uploads, [])
opts = [vault_api: FakeApi, io: FakeIO, tty?: true, email: @email] |> maybe_session(session)
ExUnit.CaptureIO.with_io(:stderr, "", fn ->
CLI.dispatch(CLI.parse(argv), opts)
end)
end
defp maybe_session(opts, nil), do: opts
defp maybe_session(opts, session), do: Keyword.put(opts, :session, session)
defp uploads, do: Process.get(:fake_vault_uploads, []) |> Enum.reverse()
defp restore(nil, key), do: System.delete_env(key)
defp restore(value, key), do: System.put_env(key, value)
# -- 加密原語:與 bear(Bear.Vault.Crypto)交叉驗證 --
describe "crypto interop (Bear.Vault.Crypto samples)" do
test "decrypts fields encrypted by Bear.Vault.Crypto" do
k_user = Base.decode64!(@k_user_b64)
assert {:ok, "GitHub"} = Crypto.decrypt(@bear_fields["name"], k_user)
assert {:ok, "s3cret-帕米拉"} = Crypto.decrypt(@bear_fields["password"], k_user)
end
test "unwraps K_user encrypted by Bear.Vault.Crypto (password path)" do
master = Crypto.derive_master_key(@master_password, @email, @iterations)
assert {:ok, key} = Crypto.unwrap_user_key(@wrapped_password, master)
assert Base.encode64(key) == @k_user_b64
end
test "rejects wrong master password (MAC failure)" do
master = Crypto.derive_master_key("wrong-password", @email, @iterations)
assert {:error, :invalid} = Crypto.unwrap_user_key(@wrapped_password, master)
end
test "rejects tampered MAC" do
k_user = Base.decode64!(@k_user_b64)
ct = Crypto.encrypt("hello", k_user)
[pre, iv, c, _mac] = String.split(ct, ".")
tampered = Enum.join([pre, iv, c, Base.encode64(:crypto.strong_rand_bytes(32))], ".")
assert {:error, :invalid} = Crypto.decrypt(tampered, k_user)
end
end
# -- BIP39 --
describe "BIP39" do
test "official vector: zero entropy → abandon…about" do
assert BIP39.mnemonic_from_entropy(<<0::128>>) == @mnemonic
end
test "official vector: mnemonic → seed" do
seed = BIP39.mnemonic_to_seed(@mnemonic)
assert Base.encode16(seed, case: :lower) ==
"5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
end
test "valid?/1 accepts official vector and rejects bad checksum" do
assert BIP39.valid?(@mnemonic)
assert BIP39.valid?(String.upcase(" #{@mnemonic} "))
# 改一個字 → 校驗和不符
refute BIP39.valid?(String.replace(@mnemonic, "about", "abandon"))
refute BIP39.valid?("not twelve words")
end
test "rescue key path unwraps wrapped_mnemonic (Bear-produced)" do
seed = BIP39.mnemonic_to_seed(@mnemonic)
rescue_key = BIP39.rescue_key_from_seed(seed, @email, @iterations)
assert {:ok, key} = Crypto.unwrap_user_key(@wrapped_mnemonic, rescue_key)
assert Base.encode64(key) == @k_user_b64
end
end
# -- 解析與用法錯誤 --
describe "parsing" do
test "unknown verb → 2" do
{code, err} = run_err(["vault", "nope"])
assert code == 2
assert err =~ "未知的 vault 子指令"
end
test "missing uuid → 2" do
{code, err} = run_err(["vault", "get"])
assert code == 2
assert err =~ "缺少 <uuid>"
end
test "create rejects bad --type → 2" do
{code, err} = run_err(["vault", "create", "--type", "photo"])
assert code == 2
assert err =~ "--type"
end
test "vault with no verb → 2" do
{code, err} = run_err(["vault"])
assert code == 2
assert err =~ "缺少子指令"
end
end
# -- status / unlock / lock --
describe "status" do
test "shows initialized + KDF + lock state" do
{code, out} = run_out(["vault", "status"])
assert code == 0
assert out =~ "已初始化"
assert out =~ "SHA-512"
assert out =~ "未解鎖"
end
test "--json reports initialized false on 404" do
FakeApi.error({:error, :not_found, "not_found"})
{code, out} =
ExUnit.CaptureIO.with_io(fn ->
CLI.dispatch(CLI.parse(["vault", "status", "--json"]),
vault_api: FakeApi,
io: FakeIO,
tty?: true,
email: @email
)
end)
assert code == 0
assert out =~ "\"initialized\":false"
end
end
describe "unlock" do
test "correct master password → session env output" do
{code, out} = run_out(["vault", "unlock"], [@master_password])
assert code == 0
assert out =~ "BEAR_VAULT_SESSION="
assert out =~ @k_user_b64
end
test "wrong master password → 1" do
{code, err} = run_err(["vault", "unlock"], ["wrong-password"])
assert code == 1
assert err =~ "主密碼不正確"
end
end
describe "lock" do
test "suggests unset when session present" do
{code, out} = run_out(["vault", "lock"], [], @k_user_b64)
assert code == 0
assert out =~ "unset BEAR_VAULT_SESSION"
end
end
# -- list / get(解密顯示)--
describe "list" do
test "locked: shows uuid/type without decrypting" do
{code, out} = run_out(["vault", "list"])
assert code == 0
assert out =~ "cipher-uuid-1"
refute out =~ "GitHub"
end
test "unlocked: decrypts names and folder (Bear-encrypted samples)" do
{code, out} = run_out(["vault", "list"], [], @k_user_b64)
assert code == 0
assert out =~ "GitHub"
assert out =~ "工作"
end
test "--folder filters by data.folder_uuid" do
{code, out} = run_out(["vault", "list", "--folder", "folder-uuid-1"], [], @k_user_b64)
assert code == 0
assert out =~ "GitHub"
{code, out} = run_out(["vault", "list", "--folder", "no-such"], [], @k_user_b64)
assert code == 0
refute out =~ "GitHub"
end
end
describe "get" do
test "unlocked: decrypts all fields (Bear-encrypted samples)" do
{code, out} = run_out(["vault", "get", "cipher-uuid-1"], [], @k_user_b64)
assert code == 0
assert out =~ "GitHub"
assert out =~ "alice@example.com"
assert out =~ "s3cret-帕米拉"
assert out =~ "https://github.com"
end
test "locked: shows ciphertext state hint" do
{code, out} = run_out(["vault", "get", "cipher-uuid-1"])
assert code == 0
assert out =~ "未解密"
end
test "404 → 1" do
{code, err} = run_err(["vault", "get", "missing-uuid"])
assert code == 1
assert err =~ "404"
end
end
# -- create(加密上傳)--
describe "create" do
test "requires session → 2" do
{code, err} = run_err(["vault", "create", "--type", "login"], [])
assert code == 2
assert err =~ "BEAR_VAULT_SESSION"
end
test "login: encrypts fields client-side and uploads" do
inputs = [
"GitHub",
"alice@example.com",
"new-secret",
"https://github.com,https://api.github.com",
"備註"
]
{code, out} =
run_out(
["vault", "create", "--type", "login", "--folder", "folder-uuid-1"],
inputs,
@k_user_b64
)
assert code == 0
assert out =~ "已建立"
assert [{:cipher_upsert, attrs}] = uploads()
# 欄位是加密字串(type 2),非明文
assert attrs["name"] =~ ~r/^2\./
assert attrs["username"] =~ ~r/^2\./
assert attrs["password"] =~ ~r/^2\./
assert length(attrs["uris"]) == 2
assert attrs["data"]["folder_uuid"] == "folder-uuid-1"
# CLI 加密 → Bear/CLI 可解回(round-trip)
assert {:ok, "GitHub"} = Crypto.decrypt(attrs["name"], Base.decode64!(@k_user_b64))
assert {:ok, "new-secret"} = Crypto.decrypt(attrs["password"], Base.decode64!(@k_user_b64))
end
test "secure_note: notes encrypted" do
inputs = ["筆記標題", "內容一二三"]
{code, _out} = run_out(["vault", "create", "--type", "secure_note"], inputs, @k_user_b64)
assert code == 0
assert [{:cipher_upsert, attrs}] = uploads()
assert {:ok, "筆記標題"} = Crypto.decrypt(attrs["name"], Base.decode64!(@k_user_b64))
assert {:ok, "內容一二三"} = Crypto.decrypt(attrs["notes"], Base.decode64!(@k_user_b64))
end
end
# -- edit --
describe "edit" do
test "Enter keeps current values; changes re-encrypted" do
# 依次:名稱(新值)、帳號(Enter 保留)、密碼(Enter 保留)、URI、備註
inputs = ["新名字", "", "", "https://github.com", ""]
{code, out} = run_out(["vault", "edit", "cipher-uuid-1"], inputs, @k_user_b64)
assert code == 0
assert out =~ "已更新"
assert [{:cipher_update, "cipher-uuid-1", attrs}] = uploads()
assert {:ok, "新名字"} = Crypto.decrypt(attrs["name"], Base.decode64!(@k_user_b64))
# 密碼 Enter 保留 → 重加密後解密仍為原明文
assert {:ok, "s3cret-帕米拉"} = Crypto.decrypt(attrs["password"], Base.decode64!(@k_user_b64))
end
end
# -- delete / restore / purge --
describe "delete / restore / purge" do
test "delete → trash endpoint" do
{code, _out} = run_out(["vault", "delete", "cipher-uuid-1"])
assert code == 0
assert [{:cipher_delete, "cipher-uuid-1"}] = uploads()
end
test "restore → restore endpoint" do
{code, _out} = run_out(["vault", "restore", "cipher-uuid-1"])
assert code == 0
assert [{:cipher_restore, "cipher-uuid-1"}] = uploads()
end
test "purge requires y confirmation" do
{code, _out} = run_out(["vault", "purge", "cipher-uuid-1"], ["n"])
assert code == 1
assert uploads() == []
{code, _out} = run_out(["vault", "purge", "cipher-uuid-1"], ["y"])
assert code == 0
assert [{:cipher_purge, "cipher-uuid-1"}] = uploads()
end
end
# -- folders --
describe "folders" do
test "list unlocked decrypts names (Bear-encrypted sample)" do
{code, out} = run_out(["vault", "folders", "list"], [], @k_user_b64)
assert code == 0
assert out =~ "folder-uuid-1"
assert out =~ "工作"
end
test "create encrypts name" do
{code, _out} = run_out(["vault", "folders", "create"], ["新資料夾"], @k_user_b64)
assert code == 0
assert [{:folder_upsert, attrs}] = uploads()
assert {:ok, "新資料夾"} = Crypto.decrypt(attrs["name"], Base.decode64!(@k_user_b64))
end
test "rename encrypts new name" do
{code, _out} = run_out(["vault", "folders", "rename", "folder-uuid-1"], ["改名"], @k_user_b64)
assert code == 0
assert [{:folder_rename, "folder-uuid-1", %{"name" => ct}}] = uploads()
assert {:ok, "改名"} = Crypto.decrypt(ct, Base.decode64!(@k_user_b64))
end
test "delete folder" do
{code, _out} = run_out(["vault", "folders", "delete", "folder-uuid-1"])
assert code == 0
assert [{:folder_delete, "folder-uuid-1"}] = uploads()
end
end
# -- sync --
describe "sync" do
test "reports counts from server payload" do
{code, out} = run_out(["vault", "sync"])
assert code == 0
assert out =~ "資料夾:1 個"
assert out =~ "項目:1 個"
end
test "--json echoes payload" do
{code, out} = run_out(["vault", "sync", "--json"])
assert code == 0
assert out =~ "\"ciphers\""
end
end
# -- password change / rescue --
describe "password change" do
test "re-wraps K_user under new master password (ciphers untouched)" do
inputs = [@master_password, "new-master-88", "new-master-88"]
{code, out} = run_out(["vault", "password", "change"], inputs)
assert code == 0
assert out =~ "主密碼已更新"
assert [{:profile, %{"wrapped_user_password" => wrapped}}] = uploads()
# 新包裝可用新主密碼解開,且解出同一把 K_user
new_master = Crypto.derive_master_key("new-master-88", @email, @iterations)
assert {:ok, key} = Crypto.unwrap_user_key(wrapped, new_master)
assert Base.encode64(key) == @k_user_b64
# 只動 wrapped_user_password
assert map_size(Process.get(:fake_vault_uploads) |> hd() |> elem(1)) == 1
end
test "mismatched new passwords → 2" do
{code, err} =
run_err(["vault", "password", "change"], [@master_password, "aaaabbbb", "ccccdddd"])
assert code == 2
assert err =~ "不一致"
end
test "short new password → 2" do
{code, err} = run_err(["vault", "password", "change"], [@master_password, "short", "short"])
assert code == 2
assert err =~ "至少需要"
end
end
describe "rescue" do
test "unwraps via mnemonic and sets new master password" do
inputs = [@mnemonic, "brand-new-99", "brand-new-99"]
{code, out} = run_out(["vault", "rescue"], inputs)
assert code == 0
assert out =~ "重設主密碼"
assert [{:profile, %{"wrapped_user_password" => wrapped}}] = uploads()
new_master = Crypto.derive_master_key("brand-new-99", @email, @iterations)
assert {:ok, key} = Crypto.unwrap_user_key(wrapped, new_master)
assert Base.encode64(key) == @k_user_b64
end
test "invalid mnemonic checksum → 1" do
bad = String.replace(@mnemonic, "about", "abandon")
{code, err} = run_err(["vault", "rescue"], [bad])
assert code == 1
assert err =~ "助記詞"
end
test "wrong-but-valid mnemonic → 1 (unwrap fails)" do
# 另一組合法助記詞,但解不開 wrapped_user_mnemonic
other = BIP39.generate()
{code, err} = run_err(["vault", "rescue"], [other, "brand-new-99", "brand-new-99"])
assert code == 1
assert err =~ "助記詞"
end
end
# -- API 錯誤分流 --
describe "api errors" do
test "401 → 3" do
FakeApi.error({:error, :unauthorized, "invalid_token"})
{code, err} = run_err(["vault", "list"])
assert code == 3
assert err =~ "重新 bear login"
end
test "403 → 8" do
FakeApi.error({:error, :forbidden, "forbidden"})
{code, err} = run_err(["vault", "list"])
assert code == 8
end
test "network → 6" do
FakeApi.error({:error, :network, "econnrefused"})
{code, err} = run_err(["vault", "list"])
assert code == 6
end
end
# -- 非 TTY --
describe "non-tty interactive" do
test "unlock without tty → 2" do
Process.put(:fake_io_inputs, [@master_password])
FakeApi.error(nil)
{code, err} =
ExUnit.CaptureIO.with_io(:stderr, "", fn ->
CLI.dispatch(CLI.parse(["vault", "unlock"]),
vault_api: FakeApi,
io: FakeIO,
tty?: false,
email: @email
)
end)
assert code == 2
assert err =~ "互動輸入"
end
end
end