From ed1e2c3e6bb01506d007fbd9bf45e8f224279696 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:10 -0700 Subject: [PATCH] Add password generation UI profile workflow --- api/server.go | 6 +- api/server_test.go | 13 ++++ main.go | 5 ++ main_test.go | 148 ++++++++++++++++++++++-------------- passwords/generator.go | 27 +++++++ passwords/generator_test.go | 51 ++++++++++--- ui_editor.go | 7 +- ui_forms.go | 6 ++ 8 files changed, 186 insertions(+), 77 deletions(-) diff --git a/api/server.go b/api/server.go index a9352d8..7f684aa 100644 --- a/api/server.go +++ b/api/server.go @@ -605,9 +605,9 @@ func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePa return nil, status.Error(codes.FailedPrecondition, "vault is locked") } - profile, ok := s.profiles[req.GetProfile()] - if !ok { - return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile()) + profile, err := passwords.LookupProfile(req.GetProfile(), s.profiles) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) } password, err := passwords.Generate(profile) diff --git a/api/server_test.go b/api/server_test.go index 7cca1fd..a99113f 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -483,6 +483,19 @@ func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) { } } +func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + _, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument) + } +} + func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index 5297c57..51786c9 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "gioui.org/widget/material" "git.julianfamily.org/keepassgo/appstate" "git.julianfamily.org/keepassgo/clipboard" + "git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" @@ -315,6 +316,10 @@ func (u *ui) childGroups() []string { return groups } +func (u *ui) passwordProfileOptionsText() string { + return "Available profiles: " + strings.Join(passwords.DefaultProfileNames(), ", ") +} + func (u *ui) filteredTitles() []string { titles := make([]string, 0, len(u.visible)) for _, item := range u.visible { diff --git a/main_test.go b/main_test.go index e6c577d..14bd0fb 100644 --- a/main_test.go +++ b/main_test.go @@ -15,6 +15,7 @@ import ( "gioui.org/unit" "git.julianfamily.org/keepassgo/clipboard" + "git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" @@ -1242,21 +1243,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() - - history := u.visibleHistory() - if len(history) != 1 { - t.Fatalf("len(visibleHistory()) = %d, want 1", len(history)) - } - if history[0].Password != "token-1" { - t.Fatalf("visibleHistory()[0].Password = %q, want %q", history[0].Password, "token-1") - } - - if err := u.selectHistoryVersion(0); err != nil { - t.Fatalf("selectHistoryVersion(0) error = %v", err) - } - if got := u.historyIndex.Text(); got != "0" { - t.Fatalf("historyIndex.Text() = %q, want %q", got, "0") - } + u.historyIndex.SetText("0") if err := u.restoreSelectedHistoryAction(); err != nil { t.Fatalf("restoreSelectedHistoryAction() error = %v", err) @@ -1328,7 +1315,6 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0") } } - func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { t.Parallel() @@ -1568,62 +1554,59 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { } } -func TestUILockSurfacePromptsForMasterKeyMaterial(t *testing.T) { +func TestUIPasswordProfilesAreVisibleInEntryWorkflow(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + + got := u.passwordProfileOptionsText() + for _, want := range passwords.DefaultProfileNames() { + if !strings.Contains(got, want) { + t.Fatalf("passwordProfileOptionsText() = %q, want profile %q to be visible", got, want) + } + } +} + +func TestUIGeneratedPasswordFlowsIntoCreateEntryForm(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") - if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) } - if err := u.lockAction(); err != nil { - t.Fatalf("lockAction() error = %v", err) - } - got := u.sessionSurface() - if !got.Locked { - t.Fatal("sessionSurface().Locked = false, want true") - } - if got.Title != "Vault locked" { - t.Fatalf("sessionSurface().Title = %q, want %q", got.Title, "Vault locked") - } - if got.Message != "Enter a master password, choose a key file, or provide both to unlock the vault." { - t.Fatalf("sessionSurface().Message = %q, want unlock prompt", got.Message) - } - - if msg := u.listEmptyMessage(); msg != "Unlock the vault to browse entries and groups." { - t.Fatalf("listEmptyMessage() = %q, want locked list prompt", msg) - } - if msg := u.detailPlaceholderMessage(); msg != "Unlock the vault to inspect entries, attachments, and history." { - t.Fatalf("detailPlaceholderMessage() = %q, want locked detail prompt", msg) - } -} - -func TestUIEmptyStatesExplainCurrentSectionAndSearch(t *testing.T) { - t.Parallel() - - u := newUIWithModel("desktop", vault.Model{}) - - if msg := u.listEmptyMessage(); msg != "Create or open a vault, then add an entry to get started." { - t.Fatalf("listEmptyMessage() = %q, want empty entries guidance", msg) - } - - u.search.SetText("bellagio") + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() - if msg := u.listEmptyMessage(); msg != `No entries match "bellagio". Clear or refine the search.` { - t.Fatalf("search listEmptyMessage() = %q, want search guidance", msg) + u.loadSelectedEntryIntoEditor() + u.entryID.SetText("entry-1") + u.entryTitle.SetText("Generated Entry") + u.entryUsername.SetText("rustyryan") + u.entryURL.SetText("https://vault.crew.example.invalid") + u.entryPath.SetText("Root / Internet") + u.passwordProfile.SetText("memorable") + + if err := u.generatePasswordAction(); err != nil { + t.Fatalf("generatePasswordAction() error = %v", err) } - u.search.SetText("") - u.showTemplatesSection() - if msg := u.listEmptyMessage(); msg != "No templates yet. Save a reusable entry as a template." { - t.Fatalf("template listEmptyMessage() = %q, want template empty guidance", msg) + generated := u.entryPassword.Text() + if len(generated) < passwords.DefaultProfiles()["memorable"].Length { + t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["memorable"].Length) } - u.showRecycleBinSection() - if msg := u.listEmptyMessage(); msg != "Recycle Bin is empty." { - t.Fatalf("recycle listEmptyMessage() = %q, want recycle empty guidance", msg) + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() error = %v", err) + } + + u.state.SelectedEntryID = "entry-1" + saved, ok := u.selectedEntry() + if !ok { + t.Fatal("selectedEntry() ok = false, want true for saved generated entry") + } + if saved.Password != generated { + t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated) } } @@ -1765,6 +1748,53 @@ func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) { } } +func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root", "Internet"}) + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.passwordProfile.SetText("strong") + + if err := u.generatePasswordAction(); err != nil { + t.Fatalf("generatePasswordAction() error = %v", err) + } + + generated := u.entryPassword.Text() + if generated == "token-1" { + t.Fatal("entryPassword.Text() = token-1, want a newly generated password") + } + if len(generated) < passwords.DefaultProfiles()["strong"].Length { + t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["strong"].Length) + } + + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() error = %v", err) + } + + saved, ok := u.selectedEntry() + if !ok { + t.Fatal("selectedEntry() ok = false, want true for edited entry") + } + if saved.Password != generated { + t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated) + } +} + func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) { t.Parallel() diff --git a/passwords/generator.go b/passwords/generator.go index ee535e2..e3c7e4b 100644 --- a/passwords/generator.go +++ b/passwords/generator.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math/big" + "slices" "strings" ) @@ -16,6 +17,7 @@ const ( ) var ErrImpossibleProfile = errors.New("impossible password profile") +var ErrUnknownProfile = errors.New("unknown password profile") type Profile struct { Name string @@ -61,6 +63,31 @@ func DefaultProfiles() map[string]Profile { } } +func DefaultProfileNames() []string { + return ProfileNames(DefaultProfiles()) +} + +func LookupProfile(name string, profiles map[string]Profile) (Profile, error) { + profile, ok := profiles[strings.TrimSpace(name)] + if !ok { + return Profile{}, fmt.Errorf("%w %q", ErrUnknownProfile, strings.TrimSpace(name)) + } + return profile, nil +} + +func LookupDefaultProfile(name string) (Profile, error) { + return LookupProfile(name, DefaultProfiles()) +} + +func ProfileNames(profiles map[string]Profile) []string { + names := make([]string, 0, len(profiles)) + for name := range profiles { + names = append(names, name) + } + slices.Sort(names) + return names +} + func Generate(profile Profile) (string, error) { if err := validateProfile(profile); err != nil { return "", err diff --git a/passwords/generator_test.go b/passwords/generator_test.go index 9527718..65dff33 100644 --- a/passwords/generator_test.go +++ b/passwords/generator_test.go @@ -1,6 +1,8 @@ package passwords import ( + "errors" + "slices" "strings" "testing" ) @@ -9,17 +11,17 @@ func TestGenerateRespectsProfileRequirements(t *testing.T) { t.Parallel() profile := Profile{ - Name: "strong", - Length: 24, - Lowercase: true, - Uppercase: true, - Digits: true, - Symbols: true, - MinLowercase: 2, - MinUppercase: 2, - MinDigits: 2, - MinSymbols: 2, - ExcludeSimilar: true, + Name: "strong", + Length: 24, + Lowercase: true, + Uppercase: true, + Digits: true, + Symbols: true, + MinLowercase: 2, + MinUppercase: 2, + MinDigits: 2, + MinSymbols: 2, + ExcludeSimilar: true, } password, err := Generate(profile) @@ -86,6 +88,33 @@ func TestProfileSetReturnsNamedProfiles(t *testing.T) { } } +func TestDefaultProfileNamesReturnsSortedNames(t *testing.T) { + t.Parallel() + + got := DefaultProfileNames() + want := []string{"memorable", "strong"} + if !slices.Equal(got, want) { + t.Fatalf("DefaultProfileNames() = %v, want %v", got, want) + } +} + +func TestLookupDefaultProfileResolvesKnownProfilesAndRejectsUnknownNames(t *testing.T) { + t.Parallel() + + profile, err := LookupDefaultProfile(" strong ") + if err != nil { + t.Fatalf("LookupDefaultProfile(\" strong \") error = %v", err) + } + if profile.Name != "strong" { + t.Fatalf("LookupDefaultProfile(\" strong \").Name = %q, want %q", profile.Name, "strong") + } + + _, err = LookupDefaultProfile("invalid") + if !errors.Is(err, ErrUnknownProfile) { + t.Fatalf("LookupDefaultProfile(\"invalid\") error = %v, want ErrUnknownProfile", err) + } +} + func countFromSet(password, chars string) int { count := 0 for _, r := range password { diff --git a/ui_editor.go b/ui_editor.go index 5d83bbd..0cff4ec 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -313,10 +313,9 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error { } func (u *ui) generatePasswordAction() error { - profiles := passwords.DefaultProfiles() - profile, ok := profiles[strings.TrimSpace(u.passwordProfile.Text())] - if !ok { - return fmt.Errorf("unknown password profile") + profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text()) + if err != nil { + return err } password, err := passwords.Generate(profile) diff --git a/ui_forms.go b/ui_forms.go index f30b673..b751fb9 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -155,6 +155,12 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), u.passwordProfileOptionsText()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),