Files
bear-cli/test/bear_cli/vault_test.exs
T
alex 0b18d76cfd 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
2026-09-10 23:27:47 +08:00

713 lines
22 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.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