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:
@@ -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
|
||||
Reference in New Issue
Block a user