email-mcp/internal/secretstore/kwallet/client_test.go
2026-04-10 11:06:39 +02:00

416 lines
13 KiB
Go

package kwallet
import (
"context"
"errors"
"reflect"
"testing"
"github.com/godbus/dbus/v5"
)
type stubCall struct {
body []any
err error
}
type recordedCall struct {
method string
args []any
}
type stubObject struct {
responses map[string][]stubCall
calls []recordedCall
}
func (o *stubObject) CallWithContext(_ context.Context, method string, _ dbus.Flags, args ...any) *dbus.Call {
o.calls = append(o.calls, recordedCall{method: method, args: append([]any(nil), args...)})
queue := o.responses[method]
if len(queue) == 0 {
return &dbus.Call{Err: errors.New("unexpected method: " + method)}
}
response := queue[0]
o.responses[method] = queue[1:]
return &dbus.Call{Body: response.body, Err: response.err}
}
type stubConnection struct {
objects map[string]*stubObject
}
func (c *stubConnection) Object(dest string, path dbus.ObjectPath) dbusObject {
return c.objects[dest+"|"+string(path)]
}
type rotatingConnection struct {
objects []dbusObject
index int
}
func (c *rotatingConnection) Object(string, dbus.ObjectPath) dbusObject {
if len(c.objects) == 0 {
return nil
}
object := c.objects[c.index]
if c.index < len(c.objects)-1 {
c.index++
}
return object
}
func TestClientIsAvailableReturnsErrorWhenServiceIsMissing(t *testing.T) {
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": {
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{err: errors.New("service missing")}},
},
},
"org.kde.kwalletd5|/modules/kwalletd5": {
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{err: errors.New("service missing")}},
},
},
},
}, nil
}))
err := client.IsAvailable(context.Background())
if err == nil {
t.Fatal("expected availability error")
}
if !errors.Is(err, ErrKWalletUnavailable) {
t.Fatalf("expected unavailable error, got %v", err)
}
}
func TestClientOpenUsesNetworkWalletAndCreatesFolder(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{false}}},
kwalletMethod("createFolder"): {{body: []any{true}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
if err := client.Open(context.Background()); err != nil {
t.Fatalf("Open returned error: %v", err)
}
wantMethods := []string{
kwalletMethod("isEnabled"),
kwalletMethod("networkWallet"),
kwalletMethod("open"),
kwalletMethod("hasFolder"),
kwalletMethod("createFolder"),
}
gotMethods := make([]string, 0, len(walletObject.calls))
for _, call := range walletObject.calls {
gotMethods = append(gotMethods, call.method)
}
if !reflect.DeepEqual(gotMethods, wantMethods) {
t.Fatalf("unexpected methods: got %v want %v", gotMethods, wantMethods)
}
openArgs := walletObject.calls[2].args
if len(openArgs) != 3 || openArgs[0] != "kdewallet" || openArgs[1] != int64(0) || openArgs[2] != kwalletAppID {
t.Fatalf("unexpected open args: %#v", openArgs)
}
}
func TestClientOpenDoesNotReopenWhenHandleZeroIsValid(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(0)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
if err := client.Open(context.Background()); err != nil {
t.Fatalf("first Open returned error: %v", err)
}
if err := client.Open(context.Background()); err != nil {
t.Fatalf("second Open returned error: %v", err)
}
openCalls := 0
for _, call := range walletObject.calls {
if call.method == kwalletMethod("open") {
openCalls++
}
}
if openCalls != 1 {
t.Fatalf("expected one open call, got %d", openCalls)
}
}
func TestClientWriteEntryWritesBytesToConfiguredFolder(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("writeEntry"): {{body: []any{int32(0)}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
if err := client.WriteEntry(context.Background(), "default", []byte("payload")); err != nil {
t.Fatalf("WriteEntry returned error: %v", err)
}
writeArgs := walletObject.calls[len(walletObject.calls)-1].args
wantArgs := []any{int32(42), kwalletFolderName, "default", []byte("payload"), kwalletAppID}
if !reflect.DeepEqual(writeArgs, wantArgs) {
t.Fatalf("unexpected write args: got %#v want %#v", writeArgs, wantArgs)
}
}
func TestClientReadEntryReturnsCredentialNotFoundWhenEntryIsMissing(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("hasEntry"): {{body: []any{false}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
_, err := client.ReadEntry(context.Background(), "default")
if err == nil {
t.Fatal("expected missing credential error")
}
if !errors.Is(err, ErrCredentialNotFound) {
t.Fatalf("expected missing credential error, got %v", err)
}
for _, call := range walletObject.calls {
if call.method == kwalletMethod("readEntry") {
t.Fatal("did not expect readEntry call when key is missing")
}
}
}
func TestClientReadEntryReadsStoredPayload(t *testing.T) {
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("hasEntry"): {{body: []any{true}}},
kwalletMethod("readEntry"): {{body: []any{[]byte("payload")}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
value, err := client.ReadEntry(context.Background(), "default")
if err != nil {
t.Fatalf("ReadEntry returned error: %v", err)
}
if got := string(value); got != "payload" {
t.Fatalf("unexpected payload: %q", got)
}
}
func TestClientWriteEntryMapsTransportFailuresToUnavailable(t *testing.T) {
wantErr := errors.New("transport closed")
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("writeEntry"): {{err: wantErr}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
err := client.WriteEntry(context.Background(), "default", []byte("payload"))
if !errors.Is(err, ErrKWalletUnavailable) {
t.Fatalf("expected unavailable error, got %v", err)
}
if !errors.Is(err, wantErr) {
t.Fatalf("expected wrapped transport error, got %v", err)
}
}
func TestClientReadEntryMapsTransportFailuresToUnavailable(t *testing.T) {
wantErr := errors.New("transport closed")
walletObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("hasEntry"): {{body: []any{true}}},
kwalletMethod("readEntry"): {{err: wantErr}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &stubConnection{
objects: map[string]*stubObject{
"org.kde.kwalletd6|/modules/kwalletd6": walletObject,
},
}, nil
}))
_, err := client.ReadEntry(context.Background(), "default")
if !errors.Is(err, ErrKWalletUnavailable) {
t.Fatalf("expected unavailable error, got %v", err)
}
if !errors.Is(err, wantErr) {
t.Fatalf("expected wrapped transport error, got %v", err)
}
}
func TestClientWriteEntryReopensAfterMappedTransportFailure(t *testing.T) {
firstObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("writeEntry"): {{err: errors.New("transport closed")}},
},
}
secondObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(43)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("writeEntry"): {{body: []any{int32(0)}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &rotatingConnection{objects: []dbusObject{firstObject, secondObject}}, nil
}))
err := client.WriteEntry(context.Background(), "default", []byte("payload"))
if !errors.Is(err, ErrKWalletUnavailable) {
t.Fatalf("expected unavailable error, got %v", err)
}
if err := client.WriteEntry(context.Background(), "default", []byte("payload")); err != nil {
t.Fatalf("expected retry to succeed, got %v", err)
}
firstOpenCalls := 0
for _, call := range firstObject.calls {
if call.method == kwalletMethod("open") {
firstOpenCalls++
}
}
secondOpenCalls := 0
for _, call := range secondObject.calls {
if call.method == kwalletMethod("open") {
secondOpenCalls++
}
}
if firstOpenCalls != 1 || secondOpenCalls != 1 {
t.Fatalf("expected reopen on retry, got first=%d second=%d", firstOpenCalls, secondOpenCalls)
}
}
func TestClientReadEntryReopensAfterMappedTransportFailure(t *testing.T) {
firstObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(42)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("hasEntry"): {{body: []any{true}}},
kwalletMethod("readEntry"): {{err: errors.New("transport closed")}},
},
}
secondObject := &stubObject{
responses: map[string][]stubCall{
kwalletMethod("isEnabled"): {{body: []any{true}}},
kwalletMethod("networkWallet"): {{body: []any{"kdewallet"}}},
kwalletMethod("open"): {{body: []any{int32(43)}}},
kwalletMethod("hasFolder"): {{body: []any{true}}},
kwalletMethod("hasEntry"): {{body: []any{true}}},
kwalletMethod("readEntry"): {{body: []any{[]byte("payload")}}},
},
}
client := newClientImpl(newWalletSession(func() (dbusConnection, error) {
return &rotatingConnection{objects: []dbusObject{firstObject, secondObject}}, nil
}))
_, err := client.ReadEntry(context.Background(), "default")
if !errors.Is(err, ErrKWalletUnavailable) {
t.Fatalf("expected unavailable error, got %v", err)
}
value, err := client.ReadEntry(context.Background(), "default")
if err != nil {
t.Fatalf("expected retry to succeed, got %v", err)
}
if string(value) != "payload" {
t.Fatalf("unexpected retry payload: %q", value)
}
firstOpenCalls := 0
for _, call := range firstObject.calls {
if call.method == kwalletMethod("open") {
firstOpenCalls++
}
}
secondOpenCalls := 0
for _, call := range secondObject.calls {
if call.method == kwalletMethod("open") {
secondOpenCalls++
}
}
if firstOpenCalls != 1 || secondOpenCalls != 1 {
t.Fatalf("expected reopen on retry, got first=%d second=%d", firstOpenCalls, secondOpenCalls)
}
}