pwstore: pass/gopass + macOS Keychain in-place adapters
Group 2 of the manager wire-up — two CLI ItemUpdaters: pass/gopass: the user's own password-store as a rotation source. Reads line-1=password + key:value metadata via `<bin> show`; updates in place by piping the full body to `<bin> insert -m -f` (off-argv), preserving metadata across the password swap. gopass enumerates via `ls --flat`, pass by walking the store dir; --pass-prefix scopes a subtree. keychain: macOS internet passwords via the `security` CLI. Pure-exec (no build tag) so it unit-tests cross-platform via a fake bin; Available() is false off darwin. Read off-argv (find-internet-password -w); write is delete+add with -w on argv (CLI limitation, documented like 1password). Both MOCK-ONLY (fake-binary unit tests + leak checks); recorded as DATA in BROWSER-ROTATION.md §8. 153 tests green, vet clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package pwstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// fakePassBin writes a `pass`/`gopass` stand-in backed by a store directory of
|
||||
// `<entry>.gpg` files whose plaintext content IS the entry body (line 1 = password).
|
||||
// It implements just the three subcommands the adapter uses: `show <entry>` (cat the
|
||||
// file), `insert -m -f <entry>` (overwrite the file from stdin), and `ls --flat` (list
|
||||
// entry names). The store dir is passed via $PASSWORD_STORE_DIR so both flavors find it.
|
||||
func fakePassBin(t *testing.T) (bin, store string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake pass binary is a POSIX shell script")
|
||||
}
|
||||
store = filepath.Join(t.TempDir(), "store")
|
||||
if err := os.MkdirAll(filepath.Join(store, "web"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// github.com/alice: password + login + url metadata lines.
|
||||
body := "old-secret\nlogin: alice@example.com\nurl: https://github.com/login\n"
|
||||
if err := os.WriteFile(filepath.Join(store, "web", "github.com.gpg"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bin = filepath.Join(t.TempDir(), "passbin")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
STORE="${PASSWORD_STORE_DIR:?need PASSWORD_STORE_DIR}"
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
show)
|
||||
entry="$2"
|
||||
cat "$STORE/$entry.gpg"
|
||||
;;
|
||||
insert)
|
||||
# insert -m -f <entry> ; body on stdin
|
||||
entry="${@: -1}"
|
||||
mkdir -p "$(dirname "$STORE/$entry.gpg")"
|
||||
cat > "$STORE/$entry.gpg"
|
||||
;;
|
||||
ls)
|
||||
# ls --flat
|
||||
( cd "$STORE" && find . -name '*.gpg' | sed -e 's#^\./##' -e 's#\.gpg$##' )
|
||||
;;
|
||||
*)
|
||||
echo "fake pass: unknown command: $*" >&2; exit 2
|
||||
;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin, store
|
||||
}
|
||||
|
||||
func TestPassStoreExport(t *testing.T) {
|
||||
for _, flavor := range []string{"pass", "gopass"} {
|
||||
t.Run(flavor, func(t *testing.T) {
|
||||
bin, store := fakePassBin(t)
|
||||
t.Setenv("PASSWORD_STORE_DIR", store) // the fake bin (and pass walk) read this
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
|
||||
p := &PassStore{Bin: bin, Flavor: flavor, Store: store}
|
||||
if !p.Available() {
|
||||
t.Fatal("Available should be true with injected bin")
|
||||
}
|
||||
accts, err := p.Export(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
if len(accts) != 1 {
|
||||
t.Fatalf("got %d accounts, want 1", len(accts))
|
||||
}
|
||||
a := accts[0]
|
||||
if a.ID != "web/github.com" {
|
||||
t.Errorf("entry path = %q, want web/github.com", a.ID)
|
||||
}
|
||||
if a.Username != "alice@example.com" {
|
||||
t.Errorf("username = %q, want alice@example.com", a.Username)
|
||||
}
|
||||
if a.Site != "github.com" {
|
||||
t.Errorf("site = %q, want github.com", a.Site)
|
||||
}
|
||||
if a.Meta["group"] != "web" {
|
||||
t.Errorf("group = %q, want web", a.Meta["group"])
|
||||
}
|
||||
if pw := handleStr(t, v, a.Secret); pw != "old-secret" {
|
||||
t.Errorf("password = %q, want old-secret", pw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassStoreUpdatePreservesMetadata(t *testing.T) {
|
||||
bin, store := fakePassBin(t)
|
||||
t.Setenv("PASSWORD_STORE_DIR", store)
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
|
||||
p := &PassStore{Bin: bin, Flavor: "pass", Store: store}
|
||||
accts, err := p.Export(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
newPw := v.Store([]byte("NEW-pass-pw-7"))
|
||||
if err := p.UpdatePassword(context.Background(), v, accts[0], newPw); err != nil {
|
||||
t.Fatalf("UpdatePassword: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(store, "web", "github.com.gpg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
// New password on line 1, OLD gone, metadata lines preserved.
|
||||
if got := s[:len("NEW-pass-pw-7")]; got != "NEW-pass-pw-7" {
|
||||
t.Errorf("first line = %q, want the new password", got)
|
||||
}
|
||||
if want := "login: alice@example.com"; !strings.Contains(s, want) {
|
||||
t.Errorf("metadata dropped: %q missing from:\n%s", want, s)
|
||||
}
|
||||
if strings.Contains(s, "old-secret") {
|
||||
t.Errorf("old password still present:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassStorePrefixScope(t *testing.T) {
|
||||
bin, store := fakePassBin(t)
|
||||
t.Setenv("PASSWORD_STORE_DIR", store)
|
||||
// Add an out-of-scope entry at the root.
|
||||
if err := os.WriteFile(filepath.Join(store, "root-note.gpg"), []byte("x\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
|
||||
p := &PassStore{Bin: bin, Flavor: "pass", Store: store, Prefix: "web"}
|
||||
accts, err := p.Export(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
if len(accts) != 1 || accts[0].ID != "web/github.com" {
|
||||
t.Fatalf("prefix scope failed: got %+v", accts)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user