Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal
This commit is contained in:
+79
-14
@@ -4,15 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"maps"
|
"maps"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
@@ -36,6 +38,8 @@ type lifecycleBackend interface {
|
|||||||
Open(string, vault.MasterKey) error
|
Open(string, vault.MasterKey) error
|
||||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||||
Save() error
|
Save() error
|
||||||
|
Lock() error
|
||||||
|
Unlock(vault.MasterKey) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
||||||
@@ -57,8 +61,8 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
return &keepassgov1.GetSessionStatusResponse{
|
return &keepassgov1.GetSessionStatusResponse{
|
||||||
Locked: s.locked,
|
Locked: s.locked,
|
||||||
Dirty: s.dirty,
|
Dirty: s.dirty,
|
||||||
EntryCount: uint32(len(s.model.Entries)),
|
EntryCount: uint32(len(s.model.Entries)),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -70,12 +74,12 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
|
|||||||
|
|
||||||
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
||||||
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
|
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "open vault: %v", err)
|
return nil, mapLifecycleError("open vault", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
model, err := s.lifecycle.Current()
|
model, err := s.lifecycle.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "load opened vault: %v", err)
|
return nil, mapLifecycleError("load opened vault", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -99,12 +103,12 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
|
|||||||
}
|
}
|
||||||
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
||||||
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
|
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "open remote vault: %v", err)
|
return nil, mapLifecycleError("open remote vault", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
model, err := s.lifecycle.Current()
|
model, err := s.lifecycle.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err)
|
return nil, mapLifecycleError("load opened remote vault", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -122,7 +126,7 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.lifecycle.Save(); err != nil {
|
if err := s.lifecycle.Save(); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "save vault: %v", err)
|
return nil, mapLifecycleError("save vault", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -133,23 +137,59 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
||||||
s.mu.Lock()
|
if s.lifecycle == nil {
|
||||||
defer s.mu.Unlock()
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.lifecycle.Lock(); err != nil {
|
||||||
|
return nil, mapLifecycleError("lock vault", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
s.locked = true
|
s.locked = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
return &keepassgov1.LockVaultResponse{}, nil
|
return &keepassgov1.LockVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
||||||
s.mu.Lock()
|
if s.lifecycle == nil {
|
||||||
defer s.mu.Unlock()
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
||||||
|
if err := s.lifecycle.Unlock(key); err != nil {
|
||||||
|
return nil, mapLifecycleError("unlock vault", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := s.lifecycle.Current()
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapLifecycleError("load unlocked vault", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.model = model
|
||||||
s.locked = false
|
s.locked = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
return &keepassgov1.UnlockVaultResponse{}, nil
|
return &keepassgov1.UnlockVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapLifecycleError(operation string, err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, os.ErrNotExist):
|
||||||
|
return status.Errorf(codes.NotFound, "%s: %v", operation, err)
|
||||||
|
case errors.Is(err, vault.ErrInvalidMasterKey):
|
||||||
|
return status.Errorf(codes.InvalidArgument, "%s: %v", operation, err)
|
||||||
|
case errors.Is(err, session.ErrLocked), errors.Is(err, session.ErrNoPath):
|
||||||
|
return status.Errorf(codes.FailedPrecondition, "%s: %v", operation, err)
|
||||||
|
case errors.Is(err, webdav.ErrConflict):
|
||||||
|
return status.Errorf(codes.Aborted, "%s: %v", operation, err)
|
||||||
|
default:
|
||||||
|
return status.Errorf(codes.Internal, "%s: %v", operation, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -224,6 +264,29 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ
|
|||||||
return &keepassgov1.RenameGroupResponse{}, nil
|
return &keepassgov1.RenameGroupResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.model.DeleteGroup(req.GetPath()); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, vault.ErrEntryNotFound):
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
case errors.Is(err, vault.ErrGroupNotEmpty):
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||||
|
default:
|
||||||
|
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.dirty = true
|
||||||
|
return &keepassgov1.DeleteGroupResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
|
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
|
||||||
if req.GetEntry() == nil {
|
if req.GetEntry() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
return nil, status.Error(codes.InvalidArgument, "missing entry")
|
||||||
@@ -565,6 +628,7 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
|
|||||||
Notes: entry.Notes,
|
Notes: entry.Notes,
|
||||||
Tags: append([]string(nil), entry.Tags...),
|
Tags: append([]string(nil), entry.Tags...),
|
||||||
Path: append([]string(nil), entry.Path...),
|
Path: append([]string(nil), entry.Path...),
|
||||||
|
Fields: maps.Clone(entry.Fields),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +642,7 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
|
|||||||
Notes: entry.GetNotes(),
|
Notes: entry.GetNotes(),
|
||||||
Tags: append([]string(nil), entry.GetTags()...),
|
Tags: append([]string(nil), entry.GetTags()...),
|
||||||
Path: append([]string(nil), entry.GetPath()...),
|
Path: append([]string(nil), entry.GetPath()...),
|
||||||
|
Fields: maps.Clone(entry.GetFields()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+358
-15
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
@@ -34,7 +35,21 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) {
|
|||||||
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
client, _, cleanup := newTestClient(t)
|
lifecycle := &stubLifecycle{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
@@ -78,6 +93,82 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lifecycle := &stubLifecycle{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unlockPassword: "correct horse battery staple",
|
||||||
|
unlockKeyFile: []byte("key-material"),
|
||||||
|
}
|
||||||
|
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{
|
||||||
|
Path: "/tmp/test.kdbx",
|
||||||
|
Password: lifecycle.unlockPassword,
|
||||||
|
KeyFileData: lifecycle.unlockKeyFile,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("OpenVault() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil {
|
||||||
|
t.Fatalf("LockVault() error = %v", err)
|
||||||
|
}
|
||||||
|
if !lifecycle.locked {
|
||||||
|
t.Fatal("LockVault() did not lock lifecycle backend")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionStatus() after lock error = %v", err)
|
||||||
|
}
|
||||||
|
if !statusResp.Locked {
|
||||||
|
t.Fatal("GetSessionStatus().Locked = false, want true after lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{
|
||||||
|
Password: "wrong password",
|
||||||
|
KeyFileData: lifecycle.unlockKeyFile,
|
||||||
|
}); status.Code(err) != codes.InvalidArgument {
|
||||||
|
t.Fatalf("UnlockVault(wrong password) code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSessionStatus() after failed unlock error = %v", err)
|
||||||
|
}
|
||||||
|
if !statusResp.Locked {
|
||||||
|
t.Fatal("GetSessionStatus().Locked = false, want true after failed unlock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{
|
||||||
|
Password: lifecycle.unlockPassword,
|
||||||
|
KeyFileData: lifecycle.unlockKeyFile,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UnlockVault() error = %v", err)
|
||||||
|
}
|
||||||
|
if lifecycle.lastUnlockKey.Password != lifecycle.unlockPassword {
|
||||||
|
t.Fatalf("UnlockVault() password = %q, want %q", lifecycle.lastUnlockKey.Password, lifecycle.unlockPassword)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) {
|
||||||
|
t.Fatalf("UnlockVault() key data = %q, want %q", lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEntries() after unlock error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" {
|
||||||
|
t.Fatalf("ListEntries().Entries = %#v, want Remote Git after unlock", listed.Entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -131,6 +222,143 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
call func() error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "open",
|
||||||
|
call: func() error {
|
||||||
|
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "open_remote",
|
||||||
|
call: func() error {
|
||||||
|
_, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
|
||||||
|
BaseUrl: "https://dav.example.com",
|
||||||
|
Path: "vaults/main.kdbx",
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "save",
|
||||||
|
call: func() error {
|
||||||
|
_, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lock",
|
||||||
|
call: func() error {
|
||||||
|
_, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unlock",
|
||||||
|
call: func() error {
|
||||||
|
_, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range testCases {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.call()
|
||||||
|
if status.Code(err) != codes.FailedPrecondition {
|
||||||
|
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), codes.FailedPrecondition)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
call func(keepassgov1.VaultServiceClient, context.Context) error
|
||||||
|
err error
|
||||||
|
want codes.Code
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "open not found",
|
||||||
|
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
|
||||||
|
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/missing.kdbx"})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
err: os.ErrNotExist,
|
||||||
|
want: codes.NotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "open invalid master key",
|
||||||
|
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
|
||||||
|
_, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
err: vault.ErrInvalidMasterKey,
|
||||||
|
want: codes.InvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "save no path",
|
||||||
|
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
|
||||||
|
_, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
err: session.ErrNoPath,
|
||||||
|
want: codes.FailedPrecondition,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lock already locked",
|
||||||
|
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
|
||||||
|
_, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
err: session.ErrLocked,
|
||||||
|
want: codes.FailedPrecondition,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unlock invalid master key",
|
||||||
|
call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error {
|
||||||
|
_, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{Password: "wrong"})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
err: vault.ErrInvalidMasterKey,
|
||||||
|
want: codes.InvalidArgument,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range testCases {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lifecycle := &stubLifecycle{err: tt.err}
|
||||||
|
client, _, cleanup := newTestClientWithLifecycle(t, lifecycle)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
err := tt.call(client, ctx)
|
||||||
|
if status.Code(err) != tt.want {
|
||||||
|
t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -150,6 +378,9 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
if resp.Entries[0].Title != "Git Server" {
|
if resp.Entries[0].Title != "Git Server" {
|
||||||
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
|
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
|
||||||
}
|
}
|
||||||
|
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
|
||||||
|
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
|
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
|
||||||
@@ -199,6 +430,42 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
|
||||||
|
ParentPath: []string{"Root"},
|
||||||
|
Name: "Finance",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{
|
||||||
|
Path: []string{"Root", "Finance"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("DeleteGroup() error = %v, want success for empty group", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGroups() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" {
|
||||||
|
t.Fatalf("ListGroups().Names = %#v, want empty Finance group removed", listed.Names)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
})
|
||||||
|
if status.Code(err) != codes.FailedPrecondition {
|
||||||
|
t.Fatalf("DeleteGroup() code = %v, want %v for non-empty group", status.Code(err), codes.FailedPrecondition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
|
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -249,7 +516,10 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
Username: "codex",
|
Username: "codex",
|
||||||
Password: "token-2",
|
Password: "token-2",
|
||||||
Url: "https://lights.julianfamily.org",
|
Url: "https://lights.julianfamily.org",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Fields: map[string]string{
|
||||||
|
"X-Role": "lights-admin",
|
||||||
|
},
|
||||||
|
Path: []string{"Root", "Home Assistant"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -259,6 +529,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
if upserted.Entry.Title != "Home Assistant (Codex)" {
|
||||||
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
|
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
|
||||||
}
|
}
|
||||||
|
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
|
||||||
|
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
|
||||||
|
}
|
||||||
|
|
||||||
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,6 +541,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
|
|||||||
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
|
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
|
||||||
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
|
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
|
||||||
}
|
}
|
||||||
|
if got := listed.Entries[0].Fields["X-Role"]; got != "lights-admin" {
|
||||||
|
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "lights-admin")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
|
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
|
||||||
@@ -324,6 +600,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
|
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
|
||||||
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
|
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
|
||||||
}
|
}
|
||||||
|
if got := templates.Templates[0].Fields["Environment"]; got != "prod" {
|
||||||
|
t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "prod")
|
||||||
|
}
|
||||||
|
|
||||||
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
|
||||||
TemplateId: "website-login",
|
TemplateId: "website-login",
|
||||||
@@ -333,8 +612,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
Username: "jjulian",
|
Username: "jjulian",
|
||||||
Password: "hunter2",
|
Password: "hunter2",
|
||||||
Url: "https://www.dynadot.com",
|
Url: "https://www.dynadot.com",
|
||||||
Path: []string{"Root", "Internet"},
|
Fields: map[string]string{
|
||||||
Tags: []string{"dns"},
|
"Environment": "staging",
|
||||||
|
},
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
Tags: []string{"dns"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -344,6 +626,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
|
|||||||
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
|
||||||
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
|
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
|
||||||
}
|
}
|
||||||
|
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
|
||||||
|
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
|
||||||
|
}
|
||||||
|
|
||||||
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,7 +653,10 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
|
|||||||
Title: "Website Login Updated",
|
Title: "Website Login Updated",
|
||||||
Username: "template-user",
|
Username: "template-user",
|
||||||
Password: "template-password",
|
Password: "template-password",
|
||||||
Path: []string{"Templates", "Web"},
|
Fields: map[string]string{
|
||||||
|
"Environment": "dev",
|
||||||
|
},
|
||||||
|
Path: []string{"Templates", "Web"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -377,6 +665,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
|
|||||||
if upserted.Template.Title != "Website Login Updated" {
|
if upserted.Template.Title != "Website Login Updated" {
|
||||||
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
|
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
|
||||||
}
|
}
|
||||||
|
if got := upserted.Template.Fields["Environment"]; got != "dev" {
|
||||||
|
t.Fatalf("UpsertTemplate().Template.Fields[Environment] = %q, want %q", got, "dev")
|
||||||
|
}
|
||||||
|
|
||||||
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -385,6 +676,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
|
|||||||
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
|
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
|
||||||
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
|
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
|
||||||
}
|
}
|
||||||
|
if got := listed.Templates[0].Fields["Environment"]; got != "dev" {
|
||||||
|
t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "dev")
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
|
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
|
||||||
t.Fatalf("DeleteTemplate() error = %v", err)
|
t.Fatalf("DeleteTemplate() error = %v", err)
|
||||||
@@ -493,6 +787,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
Username: "joejulian",
|
Username: "joejulian",
|
||||||
Password: "token-1",
|
Password: "token-1",
|
||||||
URL: "https://git.julianfamily.org",
|
URL: "https://git.julianfamily.org",
|
||||||
|
Fields: map[string]string{
|
||||||
|
"X-Role": "automation",
|
||||||
|
},
|
||||||
History: []vault.Entry{
|
History: []vault.Entry{
|
||||||
{
|
{
|
||||||
ID: "git-server-h1",
|
ID: "git-server-h1",
|
||||||
@@ -503,7 +800,7 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "ha-codex",
|
ID: "ha-codex",
|
||||||
@@ -522,8 +819,11 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
Password: "template-password",
|
Password: "template-password",
|
||||||
URL: "https://example.com",
|
URL: "https://example.com",
|
||||||
Notes: "Reusable template for website accounts.",
|
Notes: "Reusable template for website accounts.",
|
||||||
Tags: []string{"template", "web"},
|
Fields: map[string]string{
|
||||||
Path: []string{"Templates"},
|
"Environment": "prod",
|
||||||
|
},
|
||||||
|
Tags: []string{"template", "web"},
|
||||||
|
Path: []string{"Templates"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -560,7 +860,7 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass
|
|||||||
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token")))
|
||||||
clipboardWriter := &memoryClipboardWriter{}
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
|
keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle(
|
||||||
vault.Model{},
|
lifecycle.model,
|
||||||
passwords.DefaultProfiles(),
|
passwords.DefaultProfiles(),
|
||||||
clipboardWriter,
|
clipboardWriter,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
@@ -598,12 +898,16 @@ func (w *memoryClipboardWriter) WriteText(text string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stubLifecycle struct {
|
type stubLifecycle struct {
|
||||||
model vault.Model
|
model vault.Model
|
||||||
openPath string
|
openPath string
|
||||||
remoteBaseURL string
|
remoteBaseURL string
|
||||||
remotePath string
|
remotePath string
|
||||||
saved bool
|
saved bool
|
||||||
locked bool
|
locked bool
|
||||||
|
err error
|
||||||
|
unlockPassword string
|
||||||
|
unlockKeyFile []byte
|
||||||
|
lastUnlockKey vault.MasterKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubLifecycle) Current() (vault.Model, error) {
|
func (s *stubLifecycle) Current() (vault.Model, error) {
|
||||||
@@ -614,17 +918,56 @@ func (s *stubLifecycle) Current() (vault.Model, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error {
|
func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error {
|
||||||
|
if s.err != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
s.openPath = path
|
s.openPath = path
|
||||||
|
s.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
||||||
|
if s.err != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
s.remoteBaseURL = client.BaseURL
|
s.remoteBaseURL = client.BaseURL
|
||||||
s.remotePath = path
|
s.remotePath = path
|
||||||
|
s.locked = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubLifecycle) Save() error {
|
func (s *stubLifecycle) Save() error {
|
||||||
|
if s.err != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
s.saved = true
|
s.saved = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubLifecycle) Lock() error {
|
||||||
|
if s.err != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.locked = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubLifecycle) Unlock(key vault.MasterKey) error {
|
||||||
|
if s.err != nil {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
if s.unlockPassword != "" && key.Password != s.unlockPassword {
|
||||||
|
return vault.ErrInvalidMasterKey
|
||||||
|
}
|
||||||
|
if s.unlockKeyFile != nil && !bytes.Equal(key.KeyFileData, s.unlockKeyFile) {
|
||||||
|
return vault.ErrInvalidMasterKey
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastUnlockKey = vault.MasterKey{
|
||||||
|
Password: key.Password,
|
||||||
|
KeyFileData: append([]byte(nil), key.KeyFileData...),
|
||||||
|
}
|
||||||
|
s.locked = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,6 +161,19 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s State) SearchPathContext(entry vault.Entry) string {
|
||||||
|
path := slices.Clone(entry.Path)
|
||||||
|
switch s.Section {
|
||||||
|
case SectionTemplates:
|
||||||
|
if len(path) == 0 || path[0] != "Templates" {
|
||||||
|
path = append([]string{"Templates"}, path...)
|
||||||
|
}
|
||||||
|
case SectionRecycleBin:
|
||||||
|
path = append([]string{"Recycle Bin"}, path...)
|
||||||
|
}
|
||||||
|
return strings.Join(path, " / ")
|
||||||
|
}
|
||||||
|
|
||||||
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
|
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
|
||||||
var out []vault.Entry
|
var out []vault.Entry
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
|||||||
@@ -117,6 +117,142 @@ func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesUsesGlobalSearchWithinTemplateSection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Section: SectionTemplates,
|
||||||
|
CurrentPath: []string{"Templates", "Web"},
|
||||||
|
SearchQuery: "infra",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 || got[0].ID != "tpl-2" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want global template search result tpl-2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesResetToCurrentTemplatePathAfterClearingSearch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Section: SectionTemplates,
|
||||||
|
CurrentPath: []string{"Templates", "Web"},
|
||||||
|
SearchQuery: "ssh",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() with search error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].ID != "tpl-3" {
|
||||||
|
t.Fatalf("VisibleEntries() with search = %#v, want tpl-3", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.SearchQuery = ""
|
||||||
|
got, err = state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if titles := []string{got[0].Title, got[1].Title}; !slices.Equal(titles, []string{"Email Login", "Website Login"}) {
|
||||||
|
t.Fatalf("VisibleEntries() after clearing search titles = %v, want [Email Login Website Login]", titles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
RecycleBin: []vault.Entry{
|
||||||
|
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}},
|
||||||
|
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Section: SectionRecycleBin,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SearchQuery: "climate",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.VisibleEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 1 || got[0].ID != "deleted-2" {
|
||||||
|
t.Fatalf("VisibleEntries() = %#v, want global recycle-bin search result deleted-2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
section Section
|
||||||
|
entry vault.Entry
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "entries use direct path",
|
||||||
|
section: SectionEntries,
|
||||||
|
entry: vault.Entry{Path: []string{"Root", "Internet"}},
|
||||||
|
want: "Root / Internet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "templates retain templates root",
|
||||||
|
section: SectionTemplates,
|
||||||
|
entry: vault.Entry{Path: []string{"Templates", "Web"}},
|
||||||
|
want: "Templates / Web",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recycle bin prefixes root label",
|
||||||
|
section: SectionRecycleBin,
|
||||||
|
entry: vault.Entry{Path: []string{"Root", "Internet"}},
|
||||||
|
want: "Recycle Bin / Root / Internet",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{Section: tt.section}
|
||||||
|
if got := state.SearchPathContext(tt.entry); got != tt.want {
|
||||||
|
t.Fatalf("SearchPathContext(%v) = %q, want %q", tt.entry.Path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -473,6 +609,39 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "git-server",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
|
||||||
|
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldPath := sess.model.EntriesInPath([]string{"Root", "Internet"})
|
||||||
|
if len(oldPath) != 0 {
|
||||||
|
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want empty after move", oldPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
|
||||||
|
if len(newPath) != 1 || newPath[0].ID != "git-server" {
|
||||||
|
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after move")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
|
func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ type ui struct {
|
|||||||
loadingMessage string
|
loadingMessage string
|
||||||
statusMessage string
|
statusMessage string
|
||||||
errorMessage string
|
errorMessage string
|
||||||
|
keyboardFocus focusID
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -213,6 +214,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui {
|
|||||||
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
|
u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff)
|
||||||
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
|
||||||
u.passwordProfile.SetText("strong")
|
u.passwordProfile.SetText("strong")
|
||||||
|
u.keyboardFocus = focusSearch
|
||||||
u.filter()
|
u.filter()
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
@@ -267,6 +269,14 @@ func (u *ui) filteredTitles() []string {
|
|||||||
return titles
|
return titles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) visiblePathContexts() []string {
|
||||||
|
paths := make([]string, 0, len(u.visible))
|
||||||
|
for _, item := range u.visible {
|
||||||
|
paths = append(paths, u.state.SearchPathContext(item))
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) selectedEntry() (entry, bool) {
|
func (u *ui) selectedEntry() (entry, bool) {
|
||||||
for _, item := range u.visible {
|
for _, item := range u.visible {
|
||||||
if item.ID == u.state.SelectedEntryID {
|
if item.ID == u.state.SelectedEntryID {
|
||||||
@@ -772,7 +782,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
if u.mode == "phone" {
|
if u.mode == "phone" {
|
||||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
}
|
}
|
||||||
return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions {
|
return outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
|
||||||
editor := material.Editor(u.theme, &u.search, "Search vault")
|
editor := material.Editor(u.theme, &u.search, "Search vault")
|
||||||
editor.Color = u.theme.Palette.Fg
|
editor.Color = u.theme.Palette.Fg
|
||||||
editor.HintColor = mutedColor
|
editor.HintColor = mutedColor
|
||||||
@@ -860,7 +870,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
|
|||||||
if strings.TrimSpace(u.search.Text()) == "" {
|
if strings.TrimSpace(u.search.Text()) == "" {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / "))
|
lbl := material.Label(u.theme, unit.Sp(11), u.state.SearchPathContext(item))
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
@@ -876,7 +886,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if item.ID == u.state.SelectedEntryID {
|
if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) {
|
||||||
return layout.Stack{}.Layout(gtx,
|
return layout.Stack{}.Layout(gtx,
|
||||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||||
size := gtx.Constraints.Min
|
size := gtx.Constraints.Min
|
||||||
@@ -886,8 +896,14 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
|
|||||||
if size.Y == 0 {
|
if size.Y == 0 {
|
||||||
size.Y = gtx.Constraints.Max.Y
|
size.Y = gtx.Constraints.Max.Y
|
||||||
}
|
}
|
||||||
paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op())
|
fillColor := selectedColor
|
||||||
paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
|
edgeColor := selectedEdge
|
||||||
|
if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID {
|
||||||
|
fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255}
|
||||||
|
edgeColor = accentColor
|
||||||
|
}
|
||||||
|
paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op())
|
||||||
return layout.Dimensions{Size: size}
|
return layout.Dimensions{Size: size}
|
||||||
}),
|
}),
|
||||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -1101,8 +1117,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
|
|||||||
u.filter()
|
u.filter()
|
||||||
}
|
}
|
||||||
btn := material.Button(u.theme, &u.breadcrumbs[index], label)
|
btn := material.Button(u.theme, &u.breadcrumbs[index], label)
|
||||||
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
|
btn.Background, btn.Color = buttonFocusColors(u.isFocused(breadcrumbFocusID(index)))
|
||||||
btn.Color = accentColor
|
|
||||||
btn.TextSize = unit.Sp(12)
|
btn.TextSize = unit.Sp(12)
|
||||||
btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10}
|
btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10}
|
||||||
return btn.Layout(gtx)
|
return btn.Layout(gtx)
|
||||||
@@ -1221,14 +1236,14 @@ func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions {
|
||||||
border := color.NRGBA{R: 202, G: 194, B: 180, A: 255}
|
appearance := fieldFocusAppearance(gtx.Metric, focused)
|
||||||
size := gtx.Constraints.Min
|
size := gtx.Constraints.Min
|
||||||
if size.X == 0 {
|
if size.X == 0 {
|
||||||
size.X = gtx.Constraints.Max.X
|
size.X = gtx.Constraints.Max.X
|
||||||
}
|
}
|
||||||
if size.Y == 0 {
|
if size.Y == 0 {
|
||||||
size.Y = gtx.Dp(unit.Dp(44))
|
size.Y = appearance.MinHeight
|
||||||
}
|
}
|
||||||
gtx.Constraints.Min = size
|
gtx.Constraints.Min = size
|
||||||
return layout.Stack{}.Layout(gtx,
|
return layout.Stack{}.Layout(gtx,
|
||||||
@@ -1237,10 +1252,13 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|||||||
return layout.Dimensions{Size: size}
|
return layout.Dimensions{Size: size}
|
||||||
}),
|
}),
|
||||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||||
paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(size.X, 1)}.Op())
|
return drawFocusOutline(gtx, appearance, size)
|
||||||
paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op())
|
}),
|
||||||
paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(1, size.Y)}.Op())
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||||
paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
||||||
return layout.Dimensions{Size: size}
|
return layout.Dimensions{Size: size}
|
||||||
}),
|
}),
|
||||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -1253,8 +1271,8 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|||||||
if dims.Size.Y < min.Y {
|
if dims.Size.Y < min.Y {
|
||||||
dims.Size.Y = min.Y
|
dims.Size.Y = min.Y
|
||||||
}
|
}
|
||||||
if dims.Size.Y < gtx.Dp(unit.Dp(44)) {
|
if dims.Size.Y < appearance.MinHeight {
|
||||||
dims.Size.Y = gtx.Dp(unit.Dp(44))
|
dims.Size.Y = appearance.MinHeight
|
||||||
}
|
}
|
||||||
return dims
|
return dims
|
||||||
}),
|
}),
|
||||||
@@ -1263,8 +1281,7 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
|||||||
|
|
||||||
func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
|
func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions {
|
||||||
btn := material.Button(th, click, label)
|
btn := material.Button(th, click, label)
|
||||||
btn.Background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
btn.Background, btn.Color = buttonFocusColors(false)
|
||||||
btn.Color = accentColor
|
|
||||||
btn.CornerRadius = unit.Dp(10)
|
btn.CornerRadius = unit.Dp(10)
|
||||||
btn.TextSize = unit.Sp(15)
|
btn.TextSize = unit.Sp(15)
|
||||||
return btn.Layout(gtx)
|
return btn.Layout(gtx)
|
||||||
|
|||||||
+349
@@ -11,6 +11,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gioui.org/io/key"
|
||||||
|
"gioui.org/unit"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/session"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
@@ -41,6 +44,88 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
modes := []string{"desktop", "phone"}
|
||||||
|
for _, mode := range modes {
|
||||||
|
mode := mode
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel(mode, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Root", "Internet"}},
|
||||||
|
{ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||||
|
},
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
|
||||||
|
},
|
||||||
|
RecycleBin: []vault.Entry{
|
||||||
|
{ID: "deleted-1", Title: "Deleted Dynadot", URL: "https://dynadot.example.com", Path: []string{"Root", "Internet"}},
|
||||||
|
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.currentPath = []string{"Root", "Internet"}
|
||||||
|
u.search.SetText("climate")
|
||||||
|
u.filter()
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) {
|
||||||
|
t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.showTemplatesSection()
|
||||||
|
u.currentPath = []string{"Templates", "Web"}
|
||||||
|
u.search.SetText("infra")
|
||||||
|
u.filter()
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
|
||||||
|
t.Fatalf("templates filteredTitles() = %v, want [SSH Login]", got)
|
||||||
|
}
|
||||||
|
if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Templates / Infra"}) {
|
||||||
|
t.Fatalf("templates visiblePathContexts() = %v, want [Templates / Infra]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.showRecycleBinSection()
|
||||||
|
u.search.SetText("climate")
|
||||||
|
u.filter()
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) {
|
||||||
|
t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got)
|
||||||
|
}
|
||||||
|
if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) {
|
||||||
|
t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
u.showTemplatesSection()
|
||||||
|
u.currentPath = []string{"Templates", "Web"}
|
||||||
|
u.search.SetText("ssh")
|
||||||
|
u.filter()
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
|
||||||
|
t.Fatalf("filteredTitles() with search = %v, want [SSH Login]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.search.SetText("")
|
||||||
|
u.filter()
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Email Login", "Website Login"}) {
|
||||||
|
t.Fatalf("filteredTitles() after clearing search = %v, want [Email Login Website Login]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
|
func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -593,6 +678,98 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.currentPath = []string{"Root", "Internet"}
|
||||||
|
u.filter()
|
||||||
|
|
||||||
|
u.entryID.SetText("dynadot")
|
||||||
|
u.entryTitle.SetText("Dynadot")
|
||||||
|
u.entryUsername.SetText("jjulian")
|
||||||
|
u.entryPassword.SetText("token-1")
|
||||||
|
u.entryURL.SetText("https://www.dynadot.com")
|
||||||
|
u.entryNotes.SetText("Registrar account")
|
||||||
|
u.entryTags.SetText("dns, registrar")
|
||||||
|
u.entryPath.SetText("Root / Internet")
|
||||||
|
u.entryFields.SetText("Environment=prod\nAccount ID=12345")
|
||||||
|
|
||||||
|
if err := u.saveEntryAction(); err != nil {
|
||||||
|
t.Fatalf("saveEntryAction() create error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.filter()
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Dynadot"}) {
|
||||||
|
t.Fatalf("filteredTitles() = %v, want [Dynadot]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
item, ok := u.selectedEntry()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("selectedEntry() ok = false, want created entry")
|
||||||
|
}
|
||||||
|
if item.Title != "Dynadot" || item.Username != "jjulian" || item.Password != "token-1" || item.URL != "https://www.dynadot.com" {
|
||||||
|
t.Fatalf("selectedEntry() = %#v, want created Dynadot credentials", item)
|
||||||
|
}
|
||||||
|
if item.Notes != "Registrar account" {
|
||||||
|
t.Fatalf("selectedEntry().Notes = %q, want %q", item.Notes, "Registrar account")
|
||||||
|
}
|
||||||
|
if !slices.Equal(item.Tags, []string{"dns", "registrar"}) {
|
||||||
|
t.Fatalf("selectedEntry().Tags = %v, want [dns registrar]", item.Tags)
|
||||||
|
}
|
||||||
|
if item.Fields["Environment"] != "prod" || item.Fields["Account ID"] != "12345" {
|
||||||
|
t.Fatalf("selectedEntry().Fields = %#v, want parsed custom fields", item.Fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.currentPath = []string{"Root", "Internet"}
|
||||||
|
u.filter()
|
||||||
|
u.state.SelectedEntryID = "git-server"
|
||||||
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
u.entryPath.SetText("Root / Infrastructure")
|
||||||
|
|
||||||
|
if err := u.saveEntryAction(); err != nil {
|
||||||
|
t.Fatalf("saveEntryAction() move error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.currentPath = []string{"Root", "Internet"}
|
||||||
|
u.filter()
|
||||||
|
if got := u.filteredTitles(); len(got) != 0 {
|
||||||
|
t.Fatalf("filteredTitles() in old path = %v, want empty after move", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.currentPath = []string{"Root", "Infrastructure"}
|
||||||
|
u.filter()
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Git Server"}) {
|
||||||
|
t.Fatalf("filteredTitles() in new path = %v, want [Git Server]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
item, ok := u.selectedEntry()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("selectedEntry() ok = false, want moved entry")
|
||||||
|
}
|
||||||
|
if !slices.Equal(item.Path, []string{"Root", "Infrastructure"}) {
|
||||||
|
t.Fatalf("selectedEntry().Path = %v, want [Root Infrastructure]", item.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) {
|
func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -758,6 +935,178 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIKeyboardNavigationMovesAcrossBreadcrumbsListAndDetail(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "dynadot",
|
||||||
|
Title: "Dynadot",
|
||||||
|
Username: "jjulian",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.currentPath = []string{"Root", "Internet"}
|
||||||
|
u.filter()
|
||||||
|
|
||||||
|
if got := u.keyboardFocus; got != focusSearch {
|
||||||
|
t.Fatalf("keyboardFocus = %q, want %q", got, focusSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.handleKeyPress(key.NameTab, 0)
|
||||||
|
if got := u.keyboardFocus; got != breadcrumbFocusID(0) {
|
||||||
|
t.Fatalf("keyboardFocus after Tab = %q, want %q", got, breadcrumbFocusID(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
u.handleKeyPress(key.NameTab, 0)
|
||||||
|
if got := u.keyboardFocus; got != listFocusID(0) {
|
||||||
|
t.Fatalf("keyboardFocus after second Tab = %q, want %q", got, listFocusID(0))
|
||||||
|
}
|
||||||
|
if got := u.state.SelectedEntryID; got != "dynadot" {
|
||||||
|
t.Fatalf("SelectedEntryID after list focus = %q, want %q", got, "dynadot")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.handleKeyPress(key.NameDownArrow, 0)
|
||||||
|
if got := u.keyboardFocus; got != listFocusID(1) {
|
||||||
|
t.Fatalf("keyboardFocus after Down = %q, want %q", got, listFocusID(1))
|
||||||
|
}
|
||||||
|
if got := u.state.SelectedEntryID; got != "git-server" {
|
||||||
|
t.Fatalf("SelectedEntryID after Down = %q, want %q", got, "git-server")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.handleKeyPress(key.NameTab, 0)
|
||||||
|
if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) {
|
||||||
|
t.Fatalf("keyboardFocus after detail Tab = %q, want %q", got, detailFocusID(detailFieldTitle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.currentPath = []string{"Root", "Internet"}
|
||||||
|
u.filter()
|
||||||
|
u.keyboardFocus = breadcrumbFocusID(0)
|
||||||
|
|
||||||
|
u.handleKeyPress(key.NameRightArrow, 0)
|
||||||
|
if got := u.keyboardFocus; got != breadcrumbFocusID(1) {
|
||||||
|
t.Fatalf("keyboardFocus after Right = %q, want %q", got, breadcrumbFocusID(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
u.handleKeyPress(key.NameReturn, 0)
|
||||||
|
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||||
|
t.Fatalf("currentPath after breadcrumb activation = %v, want [Root]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.currentPath = []string{"Root", "Internet"}
|
||||||
|
u.filter()
|
||||||
|
u.state.SelectedEntryID = "git-server"
|
||||||
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
u.keyboardFocus = listFocusID(0)
|
||||||
|
|
||||||
|
u.handleKeyPress("F", key.ModShortcut)
|
||||||
|
if got := u.keyboardFocus; got != focusSearch {
|
||||||
|
t.Fatalf("keyboardFocus after shortcut search = %q, want %q", got, focusSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.handleKeyPress("N", key.ModShortcut)
|
||||||
|
if got := u.state.SelectedEntryID; got != "" {
|
||||||
|
t.Fatalf("SelectedEntryID after shortcut new-entry = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) {
|
||||||
|
t.Fatalf("keyboardFocus after shortcut new-entry = %q, want %q", got, detailFocusID(detailFieldTitle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "git-server",
|
||||||
|
Title: "Git Server",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.currentPath = []string{"Root", "Internet"}
|
||||||
|
u.filter()
|
||||||
|
|
||||||
|
if got := u.accessibilityLabel(focusSearch); got != "Search vault" {
|
||||||
|
t.Fatalf("accessibilityLabel(search) = %q, want %q", got, "Search vault")
|
||||||
|
}
|
||||||
|
if got := u.accessibilityLabel(breadcrumbFocusID(1)); got != "Navigate to Root" {
|
||||||
|
t.Fatalf("accessibilityLabel(breadcrumb) = %q, want %q", got, "Navigate to Root")
|
||||||
|
}
|
||||||
|
if got := u.accessibilityLabel(listFocusID(0)); got != "Select entry Git Server" {
|
||||||
|
t.Fatalf("accessibilityLabel(list) = %q, want %q", got, "Select entry Git Server")
|
||||||
|
}
|
||||||
|
if got := u.accessibilityLabel(detailFocusID(detailFieldPassword)); got != "Edit Password" {
|
||||||
|
t.Fatalf("accessibilityLabel(detail password) = %q, want %q", got, "Edit Password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, true)
|
||||||
|
hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, true)
|
||||||
|
unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, false)
|
||||||
|
|
||||||
|
if got := lo.MinHeight; got != 44 {
|
||||||
|
t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got)
|
||||||
|
}
|
||||||
|
if got := hi.MinHeight; got != 110 {
|
||||||
|
t.Fatalf("fieldFocusAppearance(high).MinHeight = %d, want 110", got)
|
||||||
|
}
|
||||||
|
if got := lo.OutlineWidth; got < 2 {
|
||||||
|
t.Fatalf("fieldFocusAppearance(low).OutlineWidth = %d, want >= 2", got)
|
||||||
|
}
|
||||||
|
if hi.OutlineWidth <= lo.OutlineWidth {
|
||||||
|
t.Fatalf("fieldFocusAppearance(high).OutlineWidth = %d, want > %d", hi.OutlineWidth, lo.OutlineWidth)
|
||||||
|
}
|
||||||
|
if lo.OutlineColor == unfocused.OutlineColor {
|
||||||
|
t.Fatalf("fieldFocusAppearance().OutlineColor focused = %#v, want distinct from unfocused %#v", lo.OutlineColor, unfocused.OutlineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
|
func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+314
-194
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ service VaultService {
|
|||||||
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
||||||
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
||||||
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
|
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
|
||||||
|
rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse);
|
||||||
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
|
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
|
||||||
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
|
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
|
||||||
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
|
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
|
||||||
@@ -67,7 +68,10 @@ message LockVaultRequest {}
|
|||||||
|
|
||||||
message LockVaultResponse {}
|
message LockVaultResponse {}
|
||||||
|
|
||||||
message UnlockVaultRequest {}
|
message UnlockVaultRequest {
|
||||||
|
string password = 1;
|
||||||
|
bytes key_file_data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message UnlockVaultResponse {}
|
message UnlockVaultResponse {}
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ message Entry {
|
|||||||
string notes = 6;
|
string notes = 6;
|
||||||
repeated string tags = 7;
|
repeated string tags = 7;
|
||||||
repeated string path = 8;
|
repeated string path = 8;
|
||||||
|
map<string, string> fields = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListEntriesResponse {
|
message ListEntriesResponse {
|
||||||
@@ -113,6 +118,12 @@ message RenameGroupRequest {
|
|||||||
|
|
||||||
message RenameGroupResponse {}
|
message RenameGroupResponse {}
|
||||||
|
|
||||||
|
message DeleteGroupRequest {
|
||||||
|
repeated string path = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteGroupResponse {}
|
||||||
|
|
||||||
message UpsertEntryRequest {
|
message UpsertEntryRequest {
|
||||||
Entry entry = 1;
|
Entry entry = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
||||||
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
||||||
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
|
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
|
||||||
|
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
|
||||||
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
|
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
|
||||||
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
|
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
|
||||||
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
|
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
|
||||||
@@ -60,6 +61,7 @@ type VaultServiceClient interface {
|
|||||||
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
||||||
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
||||||
RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error)
|
RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error)
|
||||||
|
DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error)
|
||||||
UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error)
|
UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error)
|
||||||
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
|
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
|
||||||
RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error)
|
RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error)
|
||||||
@@ -185,6 +187,16 @@ func (c *vaultServiceClient) RenameGroup(ctx context.Context, in *RenameGroupReq
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *vaultServiceClient) DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(DeleteGroupResponse)
|
||||||
|
err := c.cc.Invoke(ctx, VaultService_DeleteGroup_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) {
|
func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(UpsertEntryResponse)
|
out := new(UpsertEntryResponse)
|
||||||
@@ -349,6 +361,7 @@ type VaultServiceServer interface {
|
|||||||
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
||||||
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
||||||
RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error)
|
RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error)
|
||||||
|
DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error)
|
||||||
UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error)
|
UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error)
|
||||||
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
|
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
|
||||||
RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error)
|
RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error)
|
||||||
@@ -404,6 +417,9 @@ func (UnimplementedVaultServiceServer) CreateGroup(context.Context, *CreateGroup
|
|||||||
func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) {
|
func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedVaultServiceServer) DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method DeleteGroup not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) {
|
func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented")
|
||||||
}
|
}
|
||||||
@@ -650,6 +666,24 @@ func _VaultService_RenameGroup_Handler(srv interface{}, ctx context.Context, dec
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _VaultService_DeleteGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(DeleteGroupRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(VaultServiceServer).DeleteGroup(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: VaultService_DeleteGroup_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(VaultServiceServer).DeleteGroup(ctx, req.(*DeleteGroupRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(UpsertEntryRequest)
|
in := new(UpsertEntryRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -967,6 +1001,10 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "RenameGroup",
|
MethodName: "RenameGroup",
|
||||||
Handler: _VaultService_RenameGroup_Handler,
|
Handler: _VaultService_RenameGroup_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "DeleteGroup",
|
||||||
|
Handler: _VaultService_DeleteGroup_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "UpsertEntry",
|
MethodName: "UpsertEntry",
|
||||||
Handler: _VaultService_UpsertEntry_Handler,
|
Handler: _VaultService_UpsertEntry_Handler,
|
||||||
|
|||||||
@@ -537,3 +537,132 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
|
|||||||
t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID)
|
t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-2",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
Attachments: map[string][]byte{
|
||||||
|
"token.txt": []byte("secret attachment contents"),
|
||||||
|
},
|
||||||
|
History: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1-history-1",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "tpl-1",
|
||||||
|
Title: "Website Login",
|
||||||
|
Username: "template-user",
|
||||||
|
Password: "template-password",
|
||||||
|
Path: []string{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RecycleBin: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "deleted-1",
|
||||||
|
Title: "Retired Entry",
|
||||||
|
Username: "archived-user",
|
||||||
|
Password: "retired-token",
|
||||||
|
Path: []string{"Root", "Archive"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"Root", "Archive"},
|
||||||
|
{"Root", "Empty Group"},
|
||||||
|
{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteBytes bytes.Buffer
|
||||||
|
if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil {
|
||||||
|
t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag := "\"v1\""
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
_, _ = w.Write(remoteBytes.Bytes())
|
||||||
|
case http.MethodPut:
|
||||||
|
if got := r.Header.Get("If-Match"); got != etag {
|
||||||
|
t.Fatalf("If-Match header = %q, want %q", got, etag)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll(PUT body) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteBytes.Reset()
|
||||||
|
if _, err := remoteBytes.Write(payload); err != nil {
|
||||||
|
t.Fatalf("Write(remoteBytes) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
etag = "\"v2\""
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := webdav.Client{BaseURL: server.URL}
|
||||||
|
|
||||||
|
var sess Manager
|
||||||
|
if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
|
||||||
|
t.Fatalf("OpenRemote() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := sess.SaveRemote(); err != nil {
|
||||||
|
t.Fatalf("SaveRemote() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reopened Manager
|
||||||
|
if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil {
|
||||||
|
t.Fatalf("reopen OpenRemote() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := reopened.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Current() after reopen error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "entry-1" {
|
||||||
|
t.Fatalf("entry ID after remote reopen = %q, want %q", got[0].ID, "entry-1")
|
||||||
|
}
|
||||||
|
if len(got[0].History) != 1 || got[0].History[0].ID != "entry-1-history-1" {
|
||||||
|
t.Fatalf("History after remote reopen = %#v, want stable history ID entry-1-history-1", got[0].History)
|
||||||
|
}
|
||||||
|
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
|
||||||
|
t.Fatalf("attachment after remote reopen = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
|
||||||
|
}
|
||||||
|
if len(current.Templates) != 1 || current.Templates[0].Path[1] != "Web" {
|
||||||
|
t.Fatalf("Templates after remote reopen = %#v, want Website Login in Templates/Web", current.Templates)
|
||||||
|
}
|
||||||
|
if len(current.RecycleBin) != 1 || current.RecycleBin[0].Path[1] != "Archive" {
|
||||||
|
t.Fatalf("RecycleBin after remote reopen = %#v, want retired entry in Root/Archive", current.RecycleBin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type focusAppearance struct {
|
||||||
|
BorderColor color.NRGBA
|
||||||
|
OutlineColor color.NRGBA
|
||||||
|
OutlineWidth int
|
||||||
|
MinHeight int
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
|
||||||
|
appearance := focusAppearance{
|
||||||
|
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
|
||||||
|
OutlineColor: color.NRGBA{A: 0},
|
||||||
|
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
|
||||||
|
MinHeight: metric.Dp(unit.Dp(44)),
|
||||||
|
}
|
||||||
|
if focused {
|
||||||
|
appearance.BorderColor = accentColor
|
||||||
|
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
|
||||||
|
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
|
||||||
|
}
|
||||||
|
return appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
|
||||||
|
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||||
|
text = accentColor
|
||||||
|
if focused {
|
||||||
|
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
|
||||||
|
}
|
||||||
|
return background, text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) accessibilityLabel(id focusID) string {
|
||||||
|
switch {
|
||||||
|
case id == focusSearch:
|
||||||
|
return "Search vault"
|
||||||
|
case strings.HasPrefix(string(id), "breadcrumb:"):
|
||||||
|
index := focusIndex(id)
|
||||||
|
crumbs := u.breadcrumbLabels()
|
||||||
|
if index >= 0 && index < len(crumbs) {
|
||||||
|
return fmt.Sprintf("Navigate to %s", crumbs[index])
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(string(id), "list:"):
|
||||||
|
index := focusIndex(id)
|
||||||
|
if index >= 0 && index < len(u.visible) {
|
||||||
|
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(string(id), "detail:"):
|
||||||
|
name := strings.TrimPrefix(string(id), "detail:")
|
||||||
|
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
|
||||||
|
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
|
||||||
|
return layout.Dimensions{Size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
width := appearance.OutlineWidth
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
|
||||||
|
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
|
||||||
|
return layout.Dimensions{Size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) isFocused(id focusID) bool {
|
||||||
|
return u.keyboardFocus == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailFieldLabel(field detailField) string {
|
||||||
|
switch field {
|
||||||
|
case detailFieldID:
|
||||||
|
return "ID"
|
||||||
|
case detailFieldTitle:
|
||||||
|
return "Title"
|
||||||
|
case detailFieldUsername:
|
||||||
|
return "Username"
|
||||||
|
case detailFieldPassword:
|
||||||
|
return "Password"
|
||||||
|
case detailFieldURL:
|
||||||
|
return "URL"
|
||||||
|
case detailFieldPath:
|
||||||
|
return "Path"
|
||||||
|
case detailFieldTags:
|
||||||
|
return "Tags"
|
||||||
|
case detailFieldPasswordProfile:
|
||||||
|
return "Password Profile"
|
||||||
|
case detailFieldNotes:
|
||||||
|
return "Notes"
|
||||||
|
case detailFieldFields:
|
||||||
|
return "Custom Fields"
|
||||||
|
case detailFieldHistoryIndex:
|
||||||
|
return "History Index"
|
||||||
|
default:
|
||||||
|
return strings.ReplaceAll(string(field), "-", " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-12
@@ -111,27 +111,27 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
|
|||||||
|
|
||||||
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(labeledEditor(u.theme, "ID", &u.entryID, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "ID", &u.entryID, false, u.isFocused(detailFocusID(detailFieldID)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Title", &u.entryTitle, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Username", &u.entryUsername, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Password", &u.entryPassword, true)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "URL", &u.entryURL, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Path", &u.entryPath, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Tags", &u.entryTags, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Password Profile", &u.passwordProfile, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Custom Fields (key=value)", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(labeledEditor(u.theme, "History Index", &u.historyIndex, false)),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
switch u.state.Section {
|
switch u.state.Section {
|
||||||
@@ -211,6 +211,16 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
|
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
|
||||||
|
return labeledEditorWithFocus(th, label, editor, sensitive, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func labeledEditorWithFocus(
|
||||||
|
th *material.Theme,
|
||||||
|
label string,
|
||||||
|
editor *widget.Editor,
|
||||||
|
sensitive bool,
|
||||||
|
focused bool,
|
||||||
|
) layout.Widget {
|
||||||
return func(gtx layout.Context) layout.Dimensions {
|
return func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -219,7 +229,7 @@ func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sens
|
|||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions {
|
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
|
||||||
mask := editor.Mask
|
mask := editor.Mask
|
||||||
if sensitive {
|
if sensitive {
|
||||||
editor.Mask = '•'
|
editor.Mask = '•'
|
||||||
|
|||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gioui.org/io/key"
|
||||||
|
"git.julianfamily.org/keepassgo/appstate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type focusID string
|
||||||
|
|
||||||
|
type detailField string
|
||||||
|
|
||||||
|
const (
|
||||||
|
focusSearch focusID = "search"
|
||||||
|
|
||||||
|
detailFieldID detailField = "id"
|
||||||
|
detailFieldTitle detailField = "title"
|
||||||
|
detailFieldUsername detailField = "username"
|
||||||
|
detailFieldPassword detailField = "password"
|
||||||
|
detailFieldURL detailField = "url"
|
||||||
|
detailFieldPath detailField = "path"
|
||||||
|
detailFieldTags detailField = "tags"
|
||||||
|
detailFieldPasswordProfile detailField = "password-profile"
|
||||||
|
detailFieldNotes detailField = "notes"
|
||||||
|
detailFieldFields detailField = "fields"
|
||||||
|
detailFieldHistoryIndex detailField = "history-index"
|
||||||
|
)
|
||||||
|
|
||||||
|
func breadcrumbFocusID(index int) focusID {
|
||||||
|
return focusID(fmt.Sprintf("breadcrumb:%d", index))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listFocusID(index int) focusID {
|
||||||
|
return focusID(fmt.Sprintf("list:%d", index))
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailFocusID(field detailField) focusID {
|
||||||
|
return focusID("detail:" + string(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
|
||||||
|
if u.handleShortcutKey(name, modifiers) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case key.NameTab:
|
||||||
|
delta := 1
|
||||||
|
if modifiers.Contain(key.ModShift) {
|
||||||
|
delta = -1
|
||||||
|
}
|
||||||
|
u.moveKeyboardFocus(delta)
|
||||||
|
return true
|
||||||
|
case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn:
|
||||||
|
return u.handleFocusedKey(name)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) moveKeyboardFocus(delta int) {
|
||||||
|
order := u.focusOrder()
|
||||||
|
if len(order) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current := canonicalFocusID(u.keyboardFocus)
|
||||||
|
index := 0
|
||||||
|
for i, item := range order {
|
||||||
|
if canonicalFocusID(item) == current {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index += delta
|
||||||
|
if index < 0 {
|
||||||
|
index = len(order) - 1
|
||||||
|
}
|
||||||
|
if index >= len(order) {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
u.setKeyboardFocus(order[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) focusOrder() []focusID {
|
||||||
|
order := []focusID{focusSearch}
|
||||||
|
if u.state.Section != appstate.SectionRecycleBin {
|
||||||
|
order = append(order, breadcrumbFocusID(0))
|
||||||
|
}
|
||||||
|
if len(u.visible) > 0 {
|
||||||
|
order = append(order, listFocusID(u.focusedListIndexOrZero()))
|
||||||
|
}
|
||||||
|
order = append(order, detailFocusID(u.focusedDetailFieldOrDefault()))
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) setKeyboardFocus(id focusID) {
|
||||||
|
u.keyboardFocus = id
|
||||||
|
if strings.HasPrefix(string(id), "list:") {
|
||||||
|
u.focusListIndex(focusIndex(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) handleFocusedKey(name key.Name) bool {
|
||||||
|
switch {
|
||||||
|
case u.keyboardFocus == focusSearch:
|
||||||
|
if name == key.NameDownArrow && len(u.visible) > 0 {
|
||||||
|
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"):
|
||||||
|
return u.handleBreadcrumbKey(name)
|
||||||
|
case strings.HasPrefix(string(u.keyboardFocus), "list:"):
|
||||||
|
return u.handleListKey(name)
|
||||||
|
case strings.HasPrefix(string(u.keyboardFocus), "detail:"):
|
||||||
|
return u.handleDetailKey(name)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) handleBreadcrumbKey(name key.Name) bool {
|
||||||
|
crumbs := u.breadcrumbLabels()
|
||||||
|
if len(crumbs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
index := focusIndex(u.keyboardFocus)
|
||||||
|
switch name {
|
||||||
|
case key.NameLeftArrow:
|
||||||
|
if index > 0 {
|
||||||
|
u.keyboardFocus = breadcrumbFocusID(index - 1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.NameRightArrow:
|
||||||
|
if index < len(crumbs)-1 {
|
||||||
|
u.keyboardFocus = breadcrumbFocusID(index + 1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.NameDownArrow:
|
||||||
|
if len(u.visible) > 0 {
|
||||||
|
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.NameReturn:
|
||||||
|
u.activateBreadcrumb(index)
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) handleListKey(name key.Name) bool {
|
||||||
|
if len(u.visible) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
index := focusIndex(u.keyboardFocus)
|
||||||
|
switch name {
|
||||||
|
case key.NameUpArrow:
|
||||||
|
if index > 0 {
|
||||||
|
u.setKeyboardFocus(listFocusID(index - 1))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.NameDownArrow:
|
||||||
|
if index < len(u.visible)-1 {
|
||||||
|
u.setKeyboardFocus(listFocusID(index + 1))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.NameLeftArrow:
|
||||||
|
u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1)
|
||||||
|
return true
|
||||||
|
case key.NameRightArrow, key.NameReturn:
|
||||||
|
u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault())
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) handleDetailKey(name key.Name) bool {
|
||||||
|
fields := detailFocusOrder()
|
||||||
|
index := u.focusedDetailIndex()
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case key.NameUpArrow:
|
||||||
|
if index > 0 {
|
||||||
|
u.keyboardFocus = detailFocusID(fields[index-1])
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.NameDownArrow:
|
||||||
|
if index < len(fields)-1 {
|
||||||
|
u.keyboardFocus = detailFocusID(fields[index+1])
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case key.NameLeftArrow:
|
||||||
|
if len(u.visible) > 0 {
|
||||||
|
u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero()))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool {
|
||||||
|
if !modifiers.Contain(key.ModShortcut) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "F":
|
||||||
|
_ = u.performShortcut(shortcutSearch)
|
||||||
|
case "S":
|
||||||
|
_ = u.performShortcut(shortcutSave)
|
||||||
|
case "L":
|
||||||
|
_ = u.performShortcut(shortcutLock)
|
||||||
|
case "N":
|
||||||
|
_ = u.performShortcut(shortcutNewEntry)
|
||||||
|
case "U":
|
||||||
|
_ = u.performShortcut(shortcutCopyUser)
|
||||||
|
case "P":
|
||||||
|
_ = u.performShortcut(shortcutCopyPassword)
|
||||||
|
case "O":
|
||||||
|
_ = u.performShortcut(shortcutCopyURL)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) activateBreadcrumb(index int) {
|
||||||
|
if index <= 0 {
|
||||||
|
u.currentPath = nil
|
||||||
|
} else {
|
||||||
|
crumbs := u.breadcrumbLabels()
|
||||||
|
u.currentPath = append([]string{}, crumbs[1:index+1]...)
|
||||||
|
}
|
||||||
|
u.filter()
|
||||||
|
if index >= len(u.breadcrumbLabels()) {
|
||||||
|
index = len(u.breadcrumbLabels()) - 1
|
||||||
|
}
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
u.keyboardFocus = breadcrumbFocusID(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) breadcrumbLabels() []string {
|
||||||
|
if u.state.Section == appstate.SectionRecycleBin {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := append([]string{"Vault"}, u.currentPath...)
|
||||||
|
if u.state.Section == appstate.SectionTemplates {
|
||||||
|
labels = append([]string{"Templates"}, u.currentPath...)
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) focusListIndex(index int) {
|
||||||
|
if len(u.visible) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
if index >= len(u.visible) {
|
||||||
|
index = len(u.visible) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
u.keyboardFocus = listFocusID(index)
|
||||||
|
u.state.SelectedEntryID = u.visible[index].ID
|
||||||
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) focusedListIndexOrZero() int {
|
||||||
|
if strings.HasPrefix(string(u.keyboardFocus), "list:") {
|
||||||
|
index := focusIndex(u.keyboardFocus)
|
||||||
|
if index >= 0 && index < len(u.visible) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, item := range u.visible {
|
||||||
|
if item.ID == u.state.SelectedEntryID {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) focusedDetailFieldOrDefault() detailField {
|
||||||
|
if strings.HasPrefix(string(u.keyboardFocus), "detail:") {
|
||||||
|
name := strings.TrimPrefix(string(u.keyboardFocus), "detail:")
|
||||||
|
for _, field := range detailFocusOrder() {
|
||||||
|
if string(field) == name {
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return detailFieldTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) focusedDetailIndex() int {
|
||||||
|
current := u.focusedDetailFieldOrDefault()
|
||||||
|
for i, field := range detailFocusOrder() {
|
||||||
|
if field == current {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailFocusOrder() []detailField {
|
||||||
|
return []detailField{
|
||||||
|
detailFieldID,
|
||||||
|
detailFieldTitle,
|
||||||
|
detailFieldUsername,
|
||||||
|
detailFieldPassword,
|
||||||
|
detailFieldURL,
|
||||||
|
detailFieldPath,
|
||||||
|
detailFieldTags,
|
||||||
|
detailFieldPasswordProfile,
|
||||||
|
detailFieldNotes,
|
||||||
|
detailFieldFields,
|
||||||
|
detailFieldHistoryIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalFocusID(id focusID) focusID {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(string(id), "breadcrumb:"):
|
||||||
|
return breadcrumbFocusID(0)
|
||||||
|
case strings.HasPrefix(string(id), "list:"):
|
||||||
|
return listFocusID(0)
|
||||||
|
case strings.HasPrefix(string(id), "detail:"):
|
||||||
|
return detailFocusID(detailFieldTitle)
|
||||||
|
default:
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func focusIndex(id focusID) int {
|
||||||
|
_, value, ok := strings.Cut(string(id), ":")
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
index, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
+16
-23
@@ -24,13 +24,19 @@ func (u *ui) processShortcuts(gtx layout.Context) {
|
|||||||
event.Op(gtx.Ops, u)
|
event.Op(gtx.Ops, u)
|
||||||
for {
|
for {
|
||||||
ev, ok := gtx.Event(
|
ev, ok := gtx.Event(
|
||||||
key.Filter{Focus: u, Name: "F", Required: key.ModShortcut},
|
key.Filter{Name: "F", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "S", Required: key.ModShortcut},
|
key.Filter{Name: "S", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "L", Required: key.ModShortcut},
|
key.Filter{Name: "L", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "N", Required: key.ModShortcut},
|
key.Filter{Name: "N", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "U", Required: key.ModShortcut},
|
key.Filter{Name: "U", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "P", Required: key.ModShortcut},
|
key.Filter{Name: "P", Required: key.ModShortcut},
|
||||||
key.Filter{Focus: u, Name: "O", Required: key.ModShortcut},
|
key.Filter{Name: "O", Required: key.ModShortcut},
|
||||||
|
key.Filter{Name: key.NameTab, Optional: key.ModShift},
|
||||||
|
key.Filter{Name: key.NameLeftArrow},
|
||||||
|
key.Filter{Name: key.NameRightArrow},
|
||||||
|
key.Filter{Name: key.NameUpArrow},
|
||||||
|
key.Filter{Name: key.NameDownArrow},
|
||||||
|
key.Filter{Name: key.NameReturn},
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
@@ -41,28 +47,14 @@ func (u *ui) processShortcuts(gtx layout.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ke.Name {
|
u.handleKeyPress(ke.Name, ke.Modifiers)
|
||||||
case "F":
|
|
||||||
_ = u.performShortcut(shortcutSearch)
|
|
||||||
case "S":
|
|
||||||
_ = u.performShortcut(shortcutSave)
|
|
||||||
case "L":
|
|
||||||
_ = u.performShortcut(shortcutLock)
|
|
||||||
case "N":
|
|
||||||
_ = u.performShortcut(shortcutNewEntry)
|
|
||||||
case "U":
|
|
||||||
_ = u.performShortcut(shortcutCopyUser)
|
|
||||||
case "P":
|
|
||||||
_ = u.performShortcut(shortcutCopyPassword)
|
|
||||||
case "O":
|
|
||||||
_ = u.performShortcut(shortcutCopyURL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) performShortcut(name string) error {
|
func (u *ui) performShortcut(name string) error {
|
||||||
switch name {
|
switch name {
|
||||||
case shortcutSearch:
|
case shortcutSearch:
|
||||||
|
u.keyboardFocus = focusSearch
|
||||||
return nil
|
return nil
|
||||||
case shortcutSave:
|
case shortcutSave:
|
||||||
return u.saveAction()
|
return u.saveAction()
|
||||||
@@ -72,6 +64,7 @@ func (u *ui) performShortcut(name string) error {
|
|||||||
u.state.SelectedEntryID = ""
|
u.state.SelectedEntryID = ""
|
||||||
u.loadSelectedEntryIntoEditor()
|
u.loadSelectedEntryIntoEditor()
|
||||||
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
|
u.entryPath.SetText(strings.Join(u.currentPath, " / "))
|
||||||
|
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
||||||
return nil
|
return nil
|
||||||
case shortcutCopyUser:
|
case shortcutCopyUser:
|
||||||
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
return u.copySelectedFieldAction(clipboard.TargetUsername)
|
||||||
|
|||||||
+92
-51
@@ -23,8 +23,8 @@ type KDBXConfig struct {
|
|||||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
templatesRoot = "Templates"
|
templatesRoot = "Templates"
|
||||||
recycleBinRoot = "Recycle Bin"
|
recycleBinRoot = "Recycle Bin"
|
||||||
keepassGOIDField = "KeePassGO-ID"
|
keepassGOIDField = "KeePassGO-ID"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,33 +46,29 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
header := gokeepasslib.NewHeader()
|
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||||
|
db.Credentials = credentials
|
||||||
|
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||||
|
db.Content.Root = &gokeepasslib.RootData{}
|
||||||
if config != nil && config.Header != nil {
|
if config != nil && config.Header != nil {
|
||||||
header = cloneHeader(config.Header)
|
db.Header = cloneHeader(config.Header)
|
||||||
|
db.Hashes = gokeepasslib.NewHashes(db.Header)
|
||||||
}
|
}
|
||||||
content := &gokeepasslib.DBContent{
|
if db.Header.IsKdbx4() {
|
||||||
Meta: gokeepasslib.NewMetaData(),
|
|
||||||
Root: &gokeepasslib.RootData{},
|
|
||||||
}
|
|
||||||
if header.IsKdbx4() {
|
|
||||||
if config != nil && config.InnerHeader != nil {
|
if config != nil && config.InnerHeader != nil {
|
||||||
content.InnerHeader = cloneInnerHeader(config.InnerHeader)
|
db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader)
|
||||||
} else {
|
db.Content.InnerHeader.Binaries = nil
|
||||||
content.InnerHeader = &gokeepasslib.InnerHeader{
|
} else if db.Content.InnerHeader == nil {
|
||||||
|
db.Content.InnerHeader = &gokeepasslib.InnerHeader{
|
||||||
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
|
InnerRandomStreamID: gokeepasslib.ChaChaStreamID,
|
||||||
InnerRandomStreamKey: randomBytes(64),
|
InnerRandomStreamKey: randomBytes(64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
db.Content.InnerHeader = nil
|
||||||
}
|
}
|
||||||
|
db.Content.Root.Groups = buildGroupTree(db, model)
|
||||||
db := &gokeepasslib.Database{
|
db.Content.Root.DeletedObjects = nil
|
||||||
Header: header,
|
|
||||||
Credentials: credentials,
|
|
||||||
Content: content,
|
|
||||||
Hashes: gokeepasslib.NewHashes(header),
|
|
||||||
}
|
|
||||||
db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model))
|
|
||||||
db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin)
|
|
||||||
|
|
||||||
if err := db.LockProtectedEntries(); err != nil {
|
if err := db.LockProtectedEntries(); err != nil {
|
||||||
return fmt.Errorf("lock protected entries: %w", err)
|
return fmt.Errorf("lock protected entries: %w", err)
|
||||||
@@ -87,20 +83,21 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
|||||||
|
|
||||||
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
|
func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) {
|
||||||
path = append(clonePath(path), group.Name)
|
path = append(clonePath(path), group.Name)
|
||||||
|
model.CreateGroup(path[:len(path)-1], group.Name)
|
||||||
|
|
||||||
for _, entry := range group.Entries {
|
for _, entry := range group.Entries {
|
||||||
appendModelEntry(model, Entry{
|
appendModelEntry(model, Entry{
|
||||||
ID: extractEntryID(entry),
|
ID: extractEntryID(entry),
|
||||||
Title: entry.GetTitle(),
|
Title: entry.GetTitle(),
|
||||||
Username: entry.GetContent("UserName"),
|
Username: entry.GetContent("UserName"),
|
||||||
Password: entry.GetPassword(),
|
Password: entry.GetPassword(),
|
||||||
URL: entry.GetContent("URL"),
|
URL: entry.GetContent("URL"),
|
||||||
Notes: entry.GetContent("Notes"),
|
Notes: entry.GetContent("Notes"),
|
||||||
Tags: splitTags(entry.Tags),
|
Tags: splitTags(entry.Tags),
|
||||||
Fields: extractCustomFields(entry),
|
Fields: extractCustomFields(entry),
|
||||||
Attachments: extractAttachments(db, entry),
|
Attachments: extractAttachments(db, entry),
|
||||||
History: extractHistory(db, entry, path),
|
History: extractHistory(db, entry, path),
|
||||||
Path: clonePath(path),
|
Path: clonePath(path),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +204,7 @@ func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path []
|
|||||||
for _, item := range entry.Histories {
|
for _, item := range entry.Histories {
|
||||||
for _, historical := range item.Entries {
|
for _, historical := range item.Entries {
|
||||||
history = append(history, Entry{
|
history = append(history, Entry{
|
||||||
ID: marshalUUID(historical.UUID),
|
ID: extractEntryID(historical),
|
||||||
Title: historical.GetTitle(),
|
Title: historical.GetTitle(),
|
||||||
Username: historical.GetContent("UserName"),
|
Username: historical.GetContent("UserName"),
|
||||||
Password: historical.GetPassword(),
|
Password: historical.GetPassword(),
|
||||||
@@ -235,7 +232,8 @@ type MasterKey struct {
|
|||||||
KeyFileData []byte
|
KeyFileData []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Group {
|
func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group {
|
||||||
|
entries := entriesForPersistence(model)
|
||||||
root := &groupNode{children: map[string]*groupNode{}}
|
root := &groupNode{children: map[string]*groupNode{}}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
node := root
|
node := root
|
||||||
@@ -250,6 +248,18 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
|
|||||||
}
|
}
|
||||||
node.entries = append(node.entries, entry)
|
node.entries = append(node.entries, entry)
|
||||||
}
|
}
|
||||||
|
for _, path := range groupPathsForPersistence(model, entries) {
|
||||||
|
node := root
|
||||||
|
for _, segment := range path {
|
||||||
|
if node.children[segment] == nil {
|
||||||
|
node.children[segment] = &groupNode{
|
||||||
|
name: segment,
|
||||||
|
children: map[string]*groupNode{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.children[segment]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
groups := marshalGroups(db, root)
|
groups := marshalGroups(db, root)
|
||||||
if len(groups) > 0 {
|
if len(groups) > 0 {
|
||||||
@@ -261,6 +271,31 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G
|
|||||||
return []gokeepasslib.Group{group}
|
return []gokeepasslib.Group{group}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func groupPathsForPersistence(model Model, entries []Entry) [][]string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var groups [][]string
|
||||||
|
appendPath := func(path []string) {
|
||||||
|
key := strings.Join(path, "\x00")
|
||||||
|
if seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
groups = append(groups, slices.Clone(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
for i := 1; i <= len(entry.Path); i++ {
|
||||||
|
appendPath(entry.Path[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range model.Groups {
|
||||||
|
for i := 1; i <= len(path); i++ {
|
||||||
|
appendPath(path[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
|
func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) {
|
||||||
model, _, err := LoadKDBXWithConfig(r, key)
|
model, _, err := LoadKDBXWithConfig(r, key)
|
||||||
return model, err
|
return model, err
|
||||||
@@ -407,7 +442,7 @@ func isInvalidCredentialError(err error) bool {
|
|||||||
|
|
||||||
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
|
func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group {
|
||||||
names := slices.Collect(maps.Keys(node.children))
|
names := slices.Collect(maps.Keys(node.children))
|
||||||
slices.Sort(names)
|
slices.SortFunc(names, compareGroupNames)
|
||||||
|
|
||||||
var groups []gokeepasslib.Group
|
var groups []gokeepasslib.Group
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
@@ -422,6 +457,29 @@ func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Gr
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compareGroupNames(a, b string) int {
|
||||||
|
switch {
|
||||||
|
case a == b:
|
||||||
|
return 0
|
||||||
|
case a == "Root":
|
||||||
|
return -1
|
||||||
|
case b == "Root":
|
||||||
|
return 1
|
||||||
|
case a == templatesRoot:
|
||||||
|
return -1
|
||||||
|
case b == templatesRoot:
|
||||||
|
return 1
|
||||||
|
case a == recycleBinRoot:
|
||||||
|
return 1
|
||||||
|
case b == recycleBinRoot:
|
||||||
|
return -1
|
||||||
|
case a < b:
|
||||||
|
return -1
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
|
func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry {
|
||||||
slices.SortFunc(entries, func(a, b Entry) int {
|
slices.SortFunc(entries, func(a, b Entry) int {
|
||||||
switch {
|
switch {
|
||||||
@@ -477,23 +535,6 @@ func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
func marshalDeletedObjects(entries []Entry) []gokeepasslib.DeletedObjectData {
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
deletionTime := w.Now()
|
|
||||||
out := make([]gokeepasslib.DeletedObjectData, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
out = append(out, gokeepasslib.DeletedObjectData{
|
|
||||||
UUID: uuidForEntryID(entry.ID),
|
|
||||||
DeletionTime: &deletionTime,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func uuidForEntryID(id string) gokeepasslib.UUID {
|
func uuidForEntryID(id string) gokeepasslib.UUID {
|
||||||
if id != "" {
|
if id != "" {
|
||||||
var uuid gokeepasslib.UUID
|
var uuid gokeepasslib.UUID
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/tobischo/gokeepasslib/v3"
|
"github.com/tobischo/gokeepasslib/v3"
|
||||||
@@ -602,6 +603,114 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := Model{
|
||||||
|
Entries: []Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-2",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Notes: "Current credential",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
Attachments: map[string][]byte{
|
||||||
|
"token.txt": []byte("secret attachment contents"),
|
||||||
|
},
|
||||||
|
History: []Entry{
|
||||||
|
{
|
||||||
|
ID: "entry-1-history-1",
|
||||||
|
Title: "Git Server",
|
||||||
|
Username: "joejulian",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://git.julianfamily.org",
|
||||||
|
Notes: "Original credential",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Templates: []Entry{
|
||||||
|
{
|
||||||
|
ID: "tpl-1",
|
||||||
|
Title: "Website Login",
|
||||||
|
Username: "template-user",
|
||||||
|
Password: "template-password",
|
||||||
|
Path: []string{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RecycleBin: []Entry{
|
||||||
|
{
|
||||||
|
ID: "deleted-1",
|
||||||
|
Title: "Retired Entry",
|
||||||
|
Username: "archived-user",
|
||||||
|
Password: "retired-token",
|
||||||
|
Path: []string{"Root", "Archive"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"Root", "Archive"},
|
||||||
|
{"Root", "Empty Group"},
|
||||||
|
{"Templates", "Web"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX(first cycle) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBX(first cycle) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded.Reset()
|
||||||
|
if err := SaveKDBX(&encoded, reopened, "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX(second cycle) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err = LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBX(second cycle) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := reopened.EntriesInPath([]string{"Root", "Internet"})
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "entry-1" {
|
||||||
|
t.Fatalf("entry ID after reopen cycles = %q, want %q", got[0].ID, "entry-1")
|
||||||
|
}
|
||||||
|
if len(got[0].History) != 1 {
|
||||||
|
t.Fatalf("len(History) after reopen cycles = %d, want 1", len(got[0].History))
|
||||||
|
}
|
||||||
|
if got[0].History[0].ID != "entry-1-history-1" {
|
||||||
|
t.Fatalf("history ID after reopen cycles = %q, want %q", got[0].History[0].ID, "entry-1-history-1")
|
||||||
|
}
|
||||||
|
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
|
||||||
|
t.Fatalf("attachment after reopen cycles = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reopened.Templates) != 1 || reopened.Templates[0].Path[1] != "Web" {
|
||||||
|
t.Fatalf("Templates after reopen cycles = %#v, want Website Login in Templates/Web", reopened.Templates)
|
||||||
|
}
|
||||||
|
if len(reopened.RecycleBin) != 1 || reopened.RecycleBin[0].Path[1] != "Archive" {
|
||||||
|
t.Fatalf("RecycleBin after reopen cycles = %#v, want recycled entry in Root/Archive", reopened.RecycleBin)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootGroups := reopened.ChildGroups([]string{"Root"})
|
||||||
|
if !slices.Equal(rootGroups, []string{"Archive", "Empty Group", "Internet"}) {
|
||||||
|
t.Fatalf("ChildGroups(Root) after reopen cycles = %v, want [Archive Empty Group Internet]", rootGroups)
|
||||||
|
}
|
||||||
|
templateGroups := reopened.ChildGroups([]string{"Templates"})
|
||||||
|
if !slices.Equal(templateGroups, []string{"Web"}) {
|
||||||
|
t.Fatalf("ChildGroups(Templates) after reopen cycles = %v, want [Web]", templateGroups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustGroup(name string, children ...any) gokeepasslib.Group {
|
func mustGroup(name string, children ...any) gokeepasslib.Group {
|
||||||
group := gokeepasslib.NewGroup()
|
group := gokeepasslib.NewGroup()
|
||||||
group.Name = name
|
group.Name = name
|
||||||
|
|||||||
+13
-12
@@ -7,19 +7,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ErrEntryNotFound = errors.New("entry not found")
|
var ErrEntryNotFound = errors.New("entry not found")
|
||||||
|
var ErrGroupNotEmpty = errors.New("group is not empty")
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
URL string
|
URL string
|
||||||
Notes string
|
Notes string
|
||||||
Tags []string
|
Tags []string
|
||||||
Fields map[string]string
|
Fields map[string]string
|
||||||
Attachments map[string][]byte
|
Attachments map[string][]byte
|
||||||
History []Entry
|
History []Entry
|
||||||
Path []string
|
Path []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
@@ -323,12 +324,12 @@ func (m *Model) MoveTemplate(id string, path []string) error {
|
|||||||
func (m *Model) DeleteGroup(path []string) error {
|
func (m *Model) DeleteGroup(path []string) error {
|
||||||
for _, entry := range m.Entries {
|
for _, entry := range m.Entries {
|
||||||
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
||||||
return errors.New("group is not empty")
|
return ErrGroupNotEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, entry := range m.Templates {
|
for _, entry := range m.Templates {
|
||||||
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
||||||
return errors.New("group is not empty")
|
return ErrGroupNotEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user