mcp-framework/secretstore/bitwarden_test.go

640 lines
18 KiB
Go

package secretstore
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"testing"
"gitea.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("expected bitwarden CLI version check")
}
if !fakeCLI.statusChecked {
t.Fatal("expected bitwarden CLI status check")
}
}
func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
})
_, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrBackendUnavailable) {
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
}
}
func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) {
fakeCLI := newFakeBitwardenCLI("bw")
withBitwardenRunner(t, fakeCLI.run)
_, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
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 !errors.Is(err, ErrBackendUnavailable) {
t.Fatalf("error = %v, want ErrBackendUnavailable", 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 TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
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 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()
t.Setenv("BW_SESSION", "test-session")
}
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
})
}
type fakeBitwardenCLI struct {
command string
itemsByID map[string]fakeBitwardenItem
nextID int
status string
versionChecked bool
statusChecked bool
}
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":
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)
}