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