mcp-framework/secretstore/bitwarden_test.go
thibaud-lclr 078aa17285 fix(secretstore): relire la session Bitwarden depuis le fichier avant chaque opération
Quand un MCP appelle login/unlock, le token est écrit dans le fichier de session
mais les autres MCPs conservent leur token obsolète dans l'environnement du processus.
Désormais, bitwardenStore.ensureReady() appelle refreshSessionEnv() qui relit le
fichier avant chaque vérification, ce qui permet à tous les MCPs de rester
opérationnels après une rotation de session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:57:58 +02:00

1116 lines
31 KiB
Go

package secretstore
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"unicode/utf8"
"forge.lclr.dev/AI/mcp-framework/manifest"
)
func TestOpenSupportsBitwardenCLIBackendPolicy(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 _, ok := store.(*bitwardenStore); !ok {
t.Fatalf("store type = %T, want *bitwardenStore", store)
}
if fakeCLI.versionChecked {
t.Fatal("Open should not check bitwarden CLI version before a cache miss or write")
}
if fakeCLI.statusChecked {
t.Fatal("Open should not check bitwarden CLI status before a cache miss or write")
}
}
func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
withBitwardenSession(t)
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
})
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
_, err = store.GetSecret("api-token")
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrBackendUnavailable) {
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
}
}
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw")
withBitwardenRunner(t, fakeCLI.run)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
LookupEnv: func(name string) (string, bool) {
return "", false
},
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
_, err = store.GetSecret("api-token")
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)
}
}
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)
}
})
}
func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) {
withBitwardenSession(t)
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)
}
}
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,
})
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 != "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{
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 != "legacy-secret" {
t.Fatalf("GetSecret = %q, want legacy-secret", value)
}
}
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)
}
}
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)
}
}
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")
}
}
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)
}
}
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
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)
})
_, _, err := store.findItem("email-mcp/api-token", "api-token")
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")
if err == nil {
t.Fatal("expected error")
}
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)
}
}
func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(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)
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1")
var logs bytes.Buffer
withBitwardenDebugOutput(t, &logs)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
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)
}
}
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)
}
}
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)
}
}
var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
func stripANSIControlSequences(value string) string {
noANSI := ansiControlSequencePattern.ReplaceAllString(value, "")
return strings.ReplaceAll(noANSI, "\r", "")
}
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)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "new-session"); err != nil {
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)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "file-session"); err != nil {
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)
}
}
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
withBitwardenSession(t)
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)
}
}
func withBitwardenSession(t *testing.T) {
t.Helper()
sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
t.Setenv("BW_SESSION", "test-session-"+sessionName)
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
}
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
})
}
func withBitwardenDebugOutput(t *testing.T, writer io.Writer) {
t.Helper()
previous := bitwardenDebugOutput
bitwardenDebugOutput = writer
t.Cleanup(func() {
bitwardenDebugOutput = previous
})
}
func withBitwardenLoaderStarter(t *testing.T, starter func() func()) {
t.Helper()
previous := startBitwardenLoaderFunc
startBitwardenLoaderFunc = starter
t.Cleanup(func() {
startBitwardenLoaderFunc = previous
})
}
type fakeBitwardenCLI struct {
command string
itemsByID map[string]fakeBitwardenItem
nextID int
status string
versionChecked bool
statusChecked bool
getItemCalls int
}
type fakeBitwardenItem struct {
ID string
Name string
Notes string
Secret string
MarkerService string
MarkerSecretName string
}
func newFakeBitwardenCLI(command string) *fakeBitwardenCLI {
return &fakeBitwardenCLI{
command: strings.TrimSpace(command),
itemsByID: map[string]fakeBitwardenItem{},
nextID: 1,
status: "unlocked",
}
}
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
}
if len(args) == 1 && args[0] == "status" {
f.statusChecked = true
return []byte(fmt.Sprintf(`{"status":%q}`, strings.TrimSpace(f.status))), nil
}
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":
f.getItemCalls++
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{
"id": item.ID,
"name": item.Name,
"notes": item.Notes,
"fields": item.fieldsPayload(),
})
}
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{
"id": item.ID,
"name": item.Name,
"notes": item.Notes,
"fields": item.fieldsPayload(),
"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{
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),
}
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{
ID: trimmedID,
Name: readString(payload, "name"),
Notes: readString(payload, "notes"),
Secret: readFakeBitwardenField(payload, bitwardenSecretFieldName),
MarkerService: readFakeBitwardenField(payload, bitwardenServiceFieldName),
MarkerSecretName: readFakeBitwardenField(payload, bitwardenSecretNameFieldName),
}
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
}
func readFakeBitwardenField(payload map[string]any, fieldName string) string {
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"))
if name != fieldName {
continue
}
return readString(field, "value")
}
return ""
}
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
}
func readString(payload map[string]any, key string) string {
value, _ := payload[key].(string)
return strings.TrimSpace(value)
}