2026-04-20 06:30:35 +00:00
|
|
|
package secretstore
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-20 09:36:07 +00:00
|
|
|
"bytes"
|
2026-04-20 06:30:35 +00:00
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2026-04-20 09:36:07 +00:00
|
|
|
"io"
|
2026-04-20 12:24:43 +00:00
|
|
|
"os"
|
2026-04-20 06:30:35 +00:00
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
2026-04-20 09:18:14 +00:00
|
|
|
"regexp"
|
2026-04-20 06:30:35 +00:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
2026-04-20 09:18:14 +00:00
|
|
|
"unicode/utf8"
|
2026-04-20 06:30:35 +00:00
|
|
|
|
2026-05-05 10:23:14 +00:00
|
|
|
"forge.lclr.dev/AI/mcp-framework/manifest"
|
2026-04-20 06:30:35 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
2026-04-20 07:39:05 +00:00
|
|
|
withBitwardenSession(t)
|
2026-04-20 06:30:35 +00:00
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, ok := store.(*bitwardenStore); !ok {
|
|
|
|
|
t.Fatalf("store type = %T, want *bitwardenStore", store)
|
|
|
|
|
}
|
2026-05-02 13:30:18 +00:00
|
|
|
if fakeCLI.versionChecked {
|
|
|
|
|
t.Fatal("Open should not check bitwarden CLI version before a cache miss or write")
|
2026-04-20 06:30:35 +00:00
|
|
|
}
|
2026-05-02 13:30:18 +00:00
|
|
|
if fakeCLI.statusChecked {
|
|
|
|
|
t.Fatal("Open should not check bitwarden CLI status before a cache miss or write")
|
2026-04-20 07:39:05 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:30:18 +00:00
|
|
|
func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
2026-04-20 07:39:05 +00:00
|
|
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-02 13:30:18 +00:00
|
|
|
store, err := Open(Options{
|
2026-04-20 07:39:05 +00:00
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
2026-05-02 13:30:18 +00:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = store.GetSecret("api-token")
|
2026-04-20 07:39:05 +00:00
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error")
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, ErrBackendUnavailable) {
|
|
|
|
|
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:30:18 +00:00
|
|
|
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
|
2026-05-13 10:23:18 +00:00
|
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
|
|
|
return t.TempDir(), nil
|
|
|
|
|
})
|
2026-04-20 07:39:05 +00:00
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
2026-05-02 13:30:18 +00:00
|
|
|
store, err := Open(Options{
|
2026-04-20 07:39:05 +00:00
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
LookupEnv: func(name string) (string, bool) {
|
|
|
|
|
return "", false
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-05-02 13:30:18 +00:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = store.GetSecret("api-token")
|
2026-04-20 07:39:05 +00:00
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error")
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, ErrBWLocked) {
|
|
|
|
|
t.Fatalf("error = %v, want ErrBWLocked", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) {
|
|
|
|
|
t.Run("unauthenticated", func(t *testing.T) {
|
|
|
|
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
if len(args) == 1 && args[0] == "status" {
|
|
|
|
|
return []byte(`{"status":"unauthenticated"}`), nil
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
err := EnsureBitwardenReady(Options{BitwardenCommand: "bw"})
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error")
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, ErrBWNotLoggedIn) {
|
|
|
|
|
t.Fatalf("error = %v, want ErrBWNotLoggedIn", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "bw login") {
|
|
|
|
|
t.Fatalf("error = %v, want guidance with bw login", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("locked", func(t *testing.T) {
|
|
|
|
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
if len(args) == 1 && args[0] == "status" {
|
|
|
|
|
return []byte(`{"status":"locked"}`), nil
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
err := EnsureBitwardenReady(Options{BitwardenCommand: "bw"})
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error")
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, ErrBWLocked) {
|
|
|
|
|
t.Fatalf("error = %v, want ErrBWLocked", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "bw unlock --raw") {
|
|
|
|
|
t.Fatalf("error = %v, want guidance with bw unlock", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEnsureBitwardenReadyAcceptsUnlockedSession(t *testing.T) {
|
|
|
|
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
if len(args) == 1 && args[0] == "status" {
|
|
|
|
|
return []byte(`{"status":"unlocked"}`), nil
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
err := EnsureBitwardenReady(Options{
|
|
|
|
|
BitwardenCommand: "bw",
|
|
|
|
|
LookupEnv: func(name string) (string, bool) {
|
|
|
|
|
if name == "BW_SESSION" {
|
|
|
|
|
return "session-token", true
|
|
|
|
|
}
|
|
|
|
|
return "", false
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("EnsureBitwardenReady returned error: %v", err)
|
|
|
|
|
}
|
2026-04-20 06:30:35 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 08:56:15 +00:00
|
|
|
func TestEnsureBitwardenReadyAdaptsUnlockRemediationToShell(t *testing.T) {
|
|
|
|
|
t.Run("fish", func(t *testing.T) {
|
|
|
|
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
if len(args) == 1 && args[0] == "status" {
|
|
|
|
|
return []byte(`{"status":"locked"}`), nil
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
err := EnsureBitwardenReady(Options{BitwardenCommand: "bw", Shell: "fish"})
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error")
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, ErrBWLocked) {
|
|
|
|
|
t.Fatalf("error = %v, want ErrBWLocked", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "set -x BW_SESSION (bw unlock --raw)") {
|
|
|
|
|
t.Fatalf("error = %v, want fish unlock remediation", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("powershell", func(t *testing.T) {
|
|
|
|
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
if len(args) == 1 && args[0] == "status" {
|
|
|
|
|
return []byte(`{"status":"unlocked"}`), nil
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
err := EnsureBitwardenReady(Options{
|
|
|
|
|
BitwardenCommand: "bw",
|
|
|
|
|
Shell: "powershell",
|
|
|
|
|
LookupEnv: func(name string) (string, bool) {
|
|
|
|
|
return "", false
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error")
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, ErrBWLocked) {
|
|
|
|
|
t.Fatalf("error = %v, want ErrBWLocked", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "$env:BW_SESSION = (bw unlock --raw)") {
|
|
|
|
|
t.Fatalf("error = %v, want powershell unlock remediation", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 06:30:35 +00:00
|
|
|
func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) {
|
2026-04-20 07:39:05 +00:00
|
|
|
withBitwardenSession(t)
|
2026-04-20 06:30:35 +00:00
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "graylog-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
|
|
|
|
t.Fatalf("SetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value, err := store.GetSecret("api-token")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if value != "secret-v1" {
|
|
|
|
|
t.Fatalf("GetSecret = %q, want secret-v1", value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil {
|
|
|
|
|
t.Fatalf("SetSecret (update) returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value, err = store.GetSecret("api-token")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret (updated) returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if value != "secret-v2" {
|
|
|
|
|
t.Fatalf("GetSecret (updated) = %q, want secret-v2", value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := store.DeleteSecret("api-token"); err != nil {
|
|
|
|
|
t.Fatalf("DeleteSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = store.GetSecret("api-token")
|
|
|
|
|
if !errors.Is(err, ErrNotFound) {
|
|
|
|
|
t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 07:39:05 +00:00
|
|
|
func TestBitwardenStoreWritesMarkerFields(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
|
|
|
|
t.Fatalf("SetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var found fakeBitwardenItem
|
|
|
|
|
for _, item := range fakeCLI.itemsByID {
|
|
|
|
|
if item.Name == "email-mcp/api-token" {
|
|
|
|
|
found = item
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if found.ID == "" {
|
|
|
|
|
t.Fatal("expected bitwarden item to be created")
|
|
|
|
|
}
|
|
|
|
|
if found.MarkerService != "email-mcp" {
|
|
|
|
|
t.Fatalf("marker service = %q, want email-mcp", found.MarkerService)
|
|
|
|
|
}
|
|
|
|
|
if found.MarkerSecretName != "api-token" {
|
|
|
|
|
t.Fatalf("marker secret = %q, want api-token", found.MarkerSecretName)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBitwardenStorePrefersStrictMarkerMatchWhenNameCollides(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "wrong-secret",
|
|
|
|
|
MarkerService: "other-service",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
|
|
|
|
fakeCLI.itemsByID["item-2"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-2",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "good-secret",
|
|
|
|
|
MarkerService: "email-mcp",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
2026-04-20 06:30:35 +00:00
|
|
|
})
|
2026-04-20 07:39:05 +00:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
2026-04-20 06:30:35 +00:00
|
|
|
|
2026-04-20 07:39:05 +00:00
|
|
|
value, err := store.GetSecret("api-token")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if value != "good-secret" {
|
|
|
|
|
t.Fatalf("GetSecret = %q, want good-secret", value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBitwardenStoreFallsBackToSingleLegacyItemWithoutMarkers(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
fakeCLI.itemsByID["legacy-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "legacy-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "legacy-secret",
|
|
|
|
|
}
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
2026-04-20 06:30:35 +00:00
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
2026-04-20 07:39:05 +00:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value, err := store.GetSecret("api-token")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if value != "legacy-secret" {
|
|
|
|
|
t.Fatalf("GetSecret = %q, want legacy-secret", value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 10:03:22 +00:00
|
|
|
func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "secret-v1",
|
|
|
|
|
MarkerService: "email-mcp",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value, err := store.GetSecret("api-token")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if value != "secret-v1" {
|
|
|
|
|
t.Fatalf("GetSecret = %q, want secret-v1", value)
|
|
|
|
|
}
|
|
|
|
|
if fakeCLI.getItemCalls != 1 {
|
|
|
|
|
t.Fatalf("bw get item count = %d, want 1", fakeCLI.getItemCalls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:03:35 +00:00
|
|
|
func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "secret-v1",
|
|
|
|
|
MarkerService: "email-mcp",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
|
value, err := store.GetSecret("api-token")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
|
|
|
|
}
|
|
|
|
|
if value != "secret-v1" {
|
|
|
|
|
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if fakeCLI.getItemCalls != 1 {
|
|
|
|
|
t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:30:18 +00:00
|
|
|
func TestBitwardenStoreDiskCacheHitSkipsBitwardenCLI(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
cacheRoot := t.TempDir()
|
|
|
|
|
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
|
|
|
return cacheRoot, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cache := newBitwardenCache(bitwardenCacheOptions{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
Session: os.Getenv("BW_SESSION"),
|
|
|
|
|
TTL: defaultBitwardenCacheTTL,
|
|
|
|
|
CacheDir: resolveBitwardenCacheDir("email-mcp"),
|
|
|
|
|
Enabled: true,
|
|
|
|
|
})
|
|
|
|
|
cache.store("api-token", "email-mcp/api-token", "secret-from-cache")
|
|
|
|
|
|
|
|
|
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
return nil, fmt.Errorf("unexpected bitwarden invocation: %v", args)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value, err := store.GetSecret("api-token")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if value != "secret-from-cache" {
|
|
|
|
|
t.Fatalf("GetSecret = %q, want secret-from-cache", value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBitwardenStoreCacheMissChecksReadinessLazily(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
|
|
|
return t.TempDir(), nil
|
|
|
|
|
})
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "secret-v1",
|
|
|
|
|
MarkerService: "email-mcp",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if fakeCLI.statusChecked {
|
|
|
|
|
t.Fatal("Open should not check bitwarden status")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value, err := store.GetSecret("api-token")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if value != "secret-v1" {
|
|
|
|
|
t.Fatalf("GetSecret = %q, want secret-v1", value)
|
|
|
|
|
}
|
|
|
|
|
if !fakeCLI.statusChecked {
|
|
|
|
|
t.Fatal("cache miss should check bitwarden status before CLI lookup")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:03:35 +00:00
|
|
|
func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0")
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "secret-v1",
|
|
|
|
|
MarkerService: "email-mcp",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
|
if _, err := store.GetSecret("api-token"); err != nil {
|
|
|
|
|
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if fakeCLI.getItemCalls != 2 {
|
|
|
|
|
t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBitwardenStoreSetSecretRefreshesCache(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
|
|
|
|
t.Fatalf("SetSecret v1 returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v1" {
|
|
|
|
|
t.Fatalf("GetSecret after v1 = %q, %v; want secret-v1, nil", got, err)
|
|
|
|
|
}
|
|
|
|
|
if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil {
|
|
|
|
|
t.Fatalf("SetSecret v2 returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v2" {
|
|
|
|
|
t.Fatalf("GetSecret after v2 = %q, %v; want secret-v2, nil", got, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
|
|
|
|
t.Fatalf("SetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := store.GetSecret("api-token"); err != nil {
|
|
|
|
|
t.Fatalf("GetSecret before delete returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := store.DeleteSecret("api-token"); err != nil {
|
|
|
|
|
t.Fatalf("DeleteSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_, err = store.GetSecret("api-token")
|
|
|
|
|
if !errors.Is(err, ErrNotFound) {
|
|
|
|
|
t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 07:39:05 +00:00
|
|
|
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
|
2026-05-13 10:23:18 +00:00
|
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
|
|
|
return t.TempDir(), nil
|
|
|
|
|
})
|
2026-04-20 07:39:05 +00:00
|
|
|
store := &bitwardenStore{command: "bw", serviceName: "email-mcp"}
|
|
|
|
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" {
|
|
|
|
|
return []byte(""), nil
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-20 10:03:22 +00:00
|
|
|
_, _, err := store.findItem("email-mcp/api-token", "api-token")
|
2026-04-20 07:39:05 +00:00
|
|
|
if !errors.Is(err, ErrNotFound) {
|
|
|
|
|
t.Fatalf("error = %v, want ErrNotFound", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) {
|
|
|
|
|
if _, err := exec.LookPath("sh"); err != nil {
|
|
|
|
|
t.Skip("sh is required for this test")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := executeBitwardenCLI("sh", nil, "-c", "echo 'You are not logged in.' 1>&2; echo ' at Foo (node:internal/x)' 1>&2; exit 1")
|
2026-04-20 06:30:35 +00:00
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error")
|
|
|
|
|
}
|
2026-04-20 07:39:05 +00:00
|
|
|
if !errors.Is(err, ErrBWNotLoggedIn) {
|
|
|
|
|
t.Fatalf("error = %v, want ErrBWNotLoggedIn", err)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(err.Error(), "node:internal") {
|
|
|
|
|
t.Fatalf("error = %v, stack trace should be stripped", err)
|
2026-04-20 06:30:35 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 09:36:07 +00:00
|
|
|
func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
2026-05-02 13:30:18 +00:00
|
|
|
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
|
|
|
return t.TempDir(), nil
|
|
|
|
|
})
|
2026-04-20 09:36:07 +00:00
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
2026-05-02 13:30:18 +00:00
|
|
|
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "secret-v1",
|
|
|
|
|
MarkerService: "email-mcp",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
2026-04-20 09:36:07 +00:00
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1")
|
|
|
|
|
|
|
|
|
|
var logs bytes.Buffer
|
|
|
|
|
withBitwardenDebugOutput(t, &logs)
|
|
|
|
|
|
2026-05-02 13:30:18 +00:00
|
|
|
store, err := Open(Options{
|
2026-04-20 09:36:07 +00:00
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
2026-05-02 13:30:18 +00:00
|
|
|
if _, err := store.GetSecret("api-token"); err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
2026-04-20 09:36:07 +00:00
|
|
|
|
|
|
|
|
text := logs.String()
|
|
|
|
|
if !strings.Contains(text, "bw --version") {
|
|
|
|
|
t.Fatalf("debug logs = %q, want command bw --version", text)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(text, "bw status") {
|
|
|
|
|
t.Fatalf("debug logs = %q, want command bw status", text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBitwardenDebugRedactsSensitivePayloadArguments(t *testing.T) {
|
|
|
|
|
withBitwardenSession(t)
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
var logs bytes.Buffer
|
|
|
|
|
withBitwardenDebugOutput(t, &logs)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "graylog-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
BitwardenDebug: true,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
|
|
|
|
t.Fatalf("SetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
text := logs.String()
|
|
|
|
|
if !strings.Contains(text, "bw create item <redacted>") {
|
|
|
|
|
t.Fatalf("debug logs = %q, want redacted create payload", text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 09:18:14 +00:00
|
|
|
func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) {
|
|
|
|
|
frame := bitwardenLoaderFrame(0)
|
|
|
|
|
if !strings.HasPrefix(frame, "\r\033[2K") {
|
|
|
|
|
t.Fatalf("frame prefix = %q, want carriage return + clear line", frame)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(frame, "\033[38;5;117mW") {
|
|
|
|
|
t.Fatalf("frame = %q, want highlighted first rune", frame)
|
|
|
|
|
}
|
|
|
|
|
cleaned := stripANSIControlSequences(frame)
|
|
|
|
|
if cleaned != bitwardenLoaderMessage {
|
|
|
|
|
t.Fatalf("cleaned frame = %q, want %q", cleaned, bitwardenLoaderMessage)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBitwardenLoaderFrameMovesAndWrapsTheWave(t *testing.T) {
|
|
|
|
|
firstFrame := bitwardenLoaderFrame(0)
|
|
|
|
|
secondFrame := bitwardenLoaderFrame(1)
|
|
|
|
|
if firstFrame == secondFrame {
|
|
|
|
|
t.Fatal("expected different frames between two ticks")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(secondFrame, "\033[38;5;117ma") {
|
|
|
|
|
t.Fatalf("second frame = %q, want highlighted second rune", secondFrame)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wrapped := bitwardenLoaderFrame(utf8.RuneCountInString(bitwardenLoaderMessage))
|
|
|
|
|
if !strings.Contains(wrapped, "\033[38;5;117mW") {
|
|
|
|
|
t.Fatalf("wrapped frame = %q, want wave to wrap to first rune", wrapped)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 12:24:43 +00:00
|
|
|
func TestExecuteBitwardenCLIInteractiveSkipsLoader(t *testing.T) {
|
|
|
|
|
loaderStartCount := 0
|
|
|
|
|
withBitwardenLoaderStarter(t, func() func() {
|
|
|
|
|
loaderStartCount++
|
|
|
|
|
return func() {}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
_, err := executeBitwardenCLIInteractive(
|
|
|
|
|
os.Args[0],
|
|
|
|
|
nil,
|
|
|
|
|
io.Discard,
|
|
|
|
|
io.Discard,
|
|
|
|
|
"-test.run=^$",
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("executeBitwardenCLIInteractive returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if loaderStartCount != 0 {
|
|
|
|
|
t.Fatalf("loader start count = %d, want 0 for interactive command", loaderStartCount)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 09:18:14 +00:00
|
|
|
var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
|
|
|
|
|
|
|
|
|
|
func stripANSIControlSequences(value string) string {
|
|
|
|
|
noANSI := ansiControlSequencePattern.ReplaceAllString(value, "")
|
|
|
|
|
return strings.ReplaceAll(noANSI, "\r", "")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 10:23:18 +00:00
|
|
|
func TestBitwardenStorePicksUpSessionRotatedByAnotherProcess(t *testing.T) {
|
|
|
|
|
t.Setenv("BW_SESSION", "old-session")
|
|
|
|
|
configDir := t.TempDir()
|
|
|
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
|
|
|
return configDir, nil
|
|
|
|
|
})
|
|
|
|
|
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
|
|
|
return t.TempDir(), nil
|
|
|
|
|
})
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "secret-v1",
|
|
|
|
|
MarkerService: "email-mcp",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:56:10 +00:00
|
|
|
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "new-session"); err != nil {
|
2026-05-13 10:23:18 +00:00
|
|
|
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := store.GetSecret("api-token"); err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := os.Getenv("BW_SESSION"); got != "new-session" {
|
|
|
|
|
t.Fatalf("BW_SESSION = %q, want new-session after session rotation", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBitwardenStorePicksUpSessionFromFileWhenEnvIsEmpty(t *testing.T) {
|
|
|
|
|
t.Setenv("BW_SESSION", "")
|
|
|
|
|
configDir := t.TempDir()
|
|
|
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
|
|
|
return configDir, nil
|
|
|
|
|
})
|
|
|
|
|
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
|
|
|
return t.TempDir(), nil
|
|
|
|
|
})
|
|
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
|
|
|
ID: "item-1",
|
|
|
|
|
Name: "email-mcp/api-token",
|
|
|
|
|
Secret: "secret-v1",
|
|
|
|
|
MarkerService: "email-mcp",
|
|
|
|
|
MarkerSecretName: "api-token",
|
|
|
|
|
}
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := Open(Options{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
BackendPolicy: BackendBitwardenCLI,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 11:56:10 +00:00
|
|
|
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "file-session"); err != nil {
|
2026-05-13 10:23:18 +00:00
|
|
|
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := store.GetSecret("api-token"); err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := os.Getenv("BW_SESSION"); got != "file-session" {
|
|
|
|
|
t.Fatalf("BW_SESSION = %q, want file-session loaded from file after login by another process", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 06:30:35 +00:00
|
|
|
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
2026-04-20 07:39:05 +00:00
|
|
|
withBitwardenSession(t)
|
2026-04-20 06:30:35 +00:00
|
|
|
fakeCLI := newFakeBitwardenCLI("bw")
|
|
|
|
|
withBitwardenRunner(t, fakeCLI.run)
|
|
|
|
|
|
|
|
|
|
store, err := OpenFromManifest(OpenFromManifestOptions{
|
|
|
|
|
ServiceName: "email-mcp",
|
|
|
|
|
ExecutableResolver: func() (string, error) {
|
|
|
|
|
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
|
|
|
|
},
|
|
|
|
|
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
|
|
|
|
return manifest.File{
|
|
|
|
|
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)},
|
|
|
|
|
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("OpenFromManifest returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := store.SetSecret("smtp-password", "SMTP password", "super-secret"); err != nil {
|
|
|
|
|
t.Fatalf("SetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value, err := store.GetSecret("smtp-password")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if value != "super-secret" {
|
|
|
|
|
t.Fatalf("GetSecret = %q, want super-secret", value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 07:39:05 +00:00
|
|
|
func withBitwardenSession(t *testing.T) {
|
|
|
|
|
t.Helper()
|
2026-05-02 13:03:35 +00:00
|
|
|
sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
|
|
|
|
|
t.Setenv("BW_SESSION", "test-session-"+sessionName)
|
2026-05-02 13:30:18 +00:00
|
|
|
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
|
|
|
return t.TempDir(), nil
|
|
|
|
|
})
|
2026-05-13 10:23:18 +00:00
|
|
|
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
|
|
|
return t.TempDir(), nil
|
|
|
|
|
})
|
2026-04-20 07:39:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 06:30:35 +00:00
|
|
|
func withBitwardenRunner(
|
|
|
|
|
t *testing.T,
|
|
|
|
|
runner func(command string, stdin []byte, args ...string) ([]byte, error),
|
|
|
|
|
) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
previous := runBitwardenCLI
|
|
|
|
|
runBitwardenCLI = runner
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
runBitwardenCLI = previous
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 09:36:07 +00:00
|
|
|
func withBitwardenDebugOutput(t *testing.T, writer io.Writer) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
previous := bitwardenDebugOutput
|
|
|
|
|
bitwardenDebugOutput = writer
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
bitwardenDebugOutput = previous
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 12:24:43 +00:00
|
|
|
func withBitwardenLoaderStarter(t *testing.T, starter func() func()) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
previous := startBitwardenLoaderFunc
|
|
|
|
|
startBitwardenLoaderFunc = starter
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
startBitwardenLoaderFunc = previous
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 06:30:35 +00:00
|
|
|
type fakeBitwardenCLI struct {
|
|
|
|
|
command string
|
|
|
|
|
itemsByID map[string]fakeBitwardenItem
|
|
|
|
|
nextID int
|
2026-04-20 07:39:05 +00:00
|
|
|
status string
|
2026-04-20 06:30:35 +00:00
|
|
|
versionChecked bool
|
2026-04-20 07:39:05 +00:00
|
|
|
statusChecked bool
|
2026-04-20 10:03:22 +00:00
|
|
|
getItemCalls int
|
2026-04-20 06:30:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type fakeBitwardenItem struct {
|
2026-04-20 07:39:05 +00:00
|
|
|
ID string
|
|
|
|
|
Name string
|
|
|
|
|
Notes string
|
|
|
|
|
Secret string
|
|
|
|
|
MarkerService string
|
|
|
|
|
MarkerSecretName string
|
2026-04-20 06:30:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newFakeBitwardenCLI(command string) *fakeBitwardenCLI {
|
|
|
|
|
return &fakeBitwardenCLI{
|
|
|
|
|
command: strings.TrimSpace(command),
|
|
|
|
|
itemsByID: map[string]fakeBitwardenItem{},
|
|
|
|
|
nextID: 1,
|
2026-04-20 07:39:05 +00:00
|
|
|
status: "unlocked",
|
2026-04-20 06:30:35 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
|
|
|
if strings.TrimSpace(command) != f.command {
|
|
|
|
|
return nil, fmt.Errorf("unexpected command %q", command)
|
|
|
|
|
}
|
|
|
|
|
if len(args) == 0 {
|
|
|
|
|
return nil, errors.New("missing bitwarden CLI arguments")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(args) == 1 && args[0] == "--version" {
|
|
|
|
|
f.versionChecked = true
|
|
|
|
|
return []byte("2026.1.0\n"), nil
|
|
|
|
|
}
|
2026-04-20 07:39:05 +00:00
|
|
|
if len(args) == 1 && args[0] == "status" {
|
|
|
|
|
f.statusChecked = true
|
|
|
|
|
return []byte(fmt.Sprintf(`{"status":%q}`, strings.TrimSpace(f.status))), nil
|
|
|
|
|
}
|
2026-04-20 06:30:35 +00:00
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search":
|
|
|
|
|
return f.handleListItems(args[3])
|
|
|
|
|
case len(args) == 3 && args[0] == "get" && args[1] == "item":
|
2026-04-20 10:03:22 +00:00
|
|
|
f.getItemCalls++
|
2026-04-20 06:30:35 +00:00
|
|
|
return f.handleGetItem(args[2])
|
|
|
|
|
case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item":
|
|
|
|
|
return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil
|
|
|
|
|
case len(args) == 1 && args[0] == "encode":
|
|
|
|
|
return []byte(base64.StdEncoding.EncodeToString(stdin)), nil
|
|
|
|
|
case len(args) == 3 && args[0] == "create" && args[1] == "item":
|
|
|
|
|
return f.handleCreateItem(args[2])
|
|
|
|
|
case len(args) == 4 && args[0] == "edit" && args[1] == "item":
|
|
|
|
|
return f.handleEditItem(args[2], args[3])
|
|
|
|
|
case len(args) == 3 && args[0] == "delete" && args[1] == "item":
|
|
|
|
|
delete(f.itemsByID, strings.TrimSpace(args[2]))
|
|
|
|
|
return []byte(`{"success":true}`), nil
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("unsupported bitwarden CLI invocation: %v", args)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fakeBitwardenCLI) handleListItems(search string) ([]byte, error) {
|
|
|
|
|
needle := strings.TrimSpace(search)
|
|
|
|
|
items := make([]map[string]any, 0)
|
|
|
|
|
for _, item := range f.itemsByID {
|
|
|
|
|
if !strings.Contains(item.Name, needle) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
items = append(items, map[string]any{
|
2026-04-20 07:39:05 +00:00
|
|
|
"id": item.ID,
|
|
|
|
|
"name": item.Name,
|
|
|
|
|
"notes": item.Notes,
|
|
|
|
|
"fields": item.fieldsPayload(),
|
2026-04-20 06:30:35 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload, err := json.Marshal(items)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return payload, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fakeBitwardenCLI) handleGetItem(id string) ([]byte, error) {
|
|
|
|
|
item, ok := f.itemsByID[strings.TrimSpace(id)]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, errors.New("item not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload, err := json.Marshal(map[string]any{
|
2026-04-20 07:39:05 +00:00
|
|
|
"id": item.ID,
|
|
|
|
|
"name": item.Name,
|
|
|
|
|
"notes": item.Notes,
|
|
|
|
|
"fields": item.fieldsPayload(),
|
2026-04-20 06:30:35 +00:00
|
|
|
"secureNote": map[string]any{"type": 0},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fakeBitwardenCLI) handleCreateItem(encoded string) ([]byte, error) {
|
|
|
|
|
payload, err := decodeBitwardenPayload(encoded)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
item := fakeBitwardenItem{
|
2026-04-20 07:39:05 +00:00
|
|
|
ID: fmt.Sprintf("item-%d", f.nextID),
|
|
|
|
|
Name: readString(payload, "name"),
|
|
|
|
|
Notes: readString(payload, "notes"),
|
|
|
|
|
Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName),
|
|
|
|
|
MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName),
|
|
|
|
|
MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName),
|
2026-04-20 06:30:35 +00:00
|
|
|
}
|
|
|
|
|
f.nextID++
|
|
|
|
|
f.itemsByID[item.ID] = item
|
|
|
|
|
|
|
|
|
|
payload["id"] = item.ID
|
|
|
|
|
encodedPayload, err := json.Marshal(payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return encodedPayload, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fakeBitwardenCLI) handleEditItem(id, encoded string) ([]byte, error) {
|
|
|
|
|
trimmedID := strings.TrimSpace(id)
|
|
|
|
|
if _, ok := f.itemsByID[trimmedID]; !ok {
|
|
|
|
|
return nil, errors.New("item not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload, err := decodeBitwardenPayload(encoded)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
item := fakeBitwardenItem{
|
2026-04-20 07:39:05 +00:00
|
|
|
ID: trimmedID,
|
|
|
|
|
Name: readString(payload, "name"),
|
|
|
|
|
Notes: readString(payload, "notes"),
|
|
|
|
|
Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName),
|
|
|
|
|
MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName),
|
|
|
|
|
MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName),
|
2026-04-20 06:30:35 +00:00
|
|
|
}
|
|
|
|
|
f.itemsByID[trimmedID] = item
|
|
|
|
|
|
|
|
|
|
payload["id"] = trimmedID
|
|
|
|
|
encodedPayload, err := json.Marshal(payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return encodedPayload, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func decodeBitwardenPayload(encoded string) (map[string]any, error) {
|
|
|
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decode encoded payload: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var payload map[string]any
|
|
|
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decode payload JSON: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 07:39:05 +00:00
|
|
|
func readFakeBitwardenField(payload map[string]any, fieldName string) string {
|
2026-04-20 06:30:35 +00:00
|
|
|
rawFields, ok := payload["fields"]
|
|
|
|
|
if !ok {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fields, ok := rawFields.([]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, rawField := range fields {
|
|
|
|
|
field, ok := rawField.(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
name := strings.TrimSpace(readString(field, "name"))
|
2026-04-20 07:39:05 +00:00
|
|
|
if name != fieldName {
|
2026-04-20 06:30:35 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return readString(field, "value")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 07:39:05 +00:00
|
|
|
func (i fakeBitwardenItem) fieldsPayload() []map[string]any {
|
|
|
|
|
fields := []map[string]any{
|
|
|
|
|
{"name": bitwardenSecretFieldName, "value": i.Secret, "type": 1},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(i.MarkerService) != "" {
|
|
|
|
|
fields = append(fields, map[string]any{"name": bitwardenServiceFieldName, "value": i.MarkerService, "type": 0})
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(i.MarkerSecretName) != "" {
|
|
|
|
|
fields = append(fields, map[string]any{"name": bitwardenSecretNameFieldName, "value": i.MarkerSecretName, "type": 0})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fields
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 06:30:35 +00:00
|
|
|
func readString(payload map[string]any, key string) string {
|
|
|
|
|
value, _ := payload[key].(string)
|
|
|
|
|
return strings.TrimSpace(value)
|
|
|
|
|
}
|