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) }