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