342 lines
8.6 KiB
Go
342 lines
8.6 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) {
|
||
|
|
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")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBitwardenStoreSetGetDeleteSecret(t *testing.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 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 TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.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 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
|
||
|
|
versionChecked bool
|
||
|
|
}
|
||
|
|
|
||
|
|
type fakeBitwardenItem struct {
|
||
|
|
ID string
|
||
|
|
Name string
|
||
|
|
Notes string
|
||
|
|
Secret string
|
||
|
|
}
|
||
|
|
|
||
|
|
func newFakeBitwardenCLI(command string) *fakeBitwardenCLI {
|
||
|
|
return &fakeBitwardenCLI{
|
||
|
|
command: strings.TrimSpace(command),
|
||
|
|
itemsByID: map[string]fakeBitwardenItem{},
|
||
|
|
nextID: 1,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
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": []map[string]any{
|
||
|
|
{"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
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": []map[string]any{
|
||
|
|
{"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1},
|
||
|
|
},
|
||
|
|
"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: readFakeBitwardenSecret(payload),
|
||
|
|
}
|
||
|
|
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: readFakeBitwardenSecret(payload),
|
||
|
|
}
|
||
|
|
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 readFakeBitwardenSecret(payload map[string]any) 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 != bitwardenSecretFieldName {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
return readString(field, "value")
|
||
|
|
}
|
||
|
|
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func readString(payload map[string]any, key string) string {
|
||
|
|
value, _ := payload[key].(string)
|
||
|
|
return strings.TrimSpace(value)
|
||
|
|
}
|