Compare commits

..

1 Commits

Author SHA1 Message Date
Joe Julian 51f2a0121a Fix extra string actions
ci / lint-test (pull_request) Successful in 5m26s
ci / build (pull_request) Successful in 6m28s
2026-04-28 21:31:40 -07:00
12 changed files with 307 additions and 121 deletions
-4
View File
@@ -116,10 +116,6 @@ These features are product requirements, not “nice to have” ideas.
- UI state should not be the source of truth for vault structure or search behavior. - UI state should not be the source of truth for vault structure or search behavior.
- Domain packages must be test-driven where practical. - Domain packages must be test-driven where practical.
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details. - Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
- Prefer simplifying refactors that extract shared behavior into smaller named
functions. When a new path needs most of an existing function, factor the
common behavior out and let the specific functions call it instead of adding
flags or branches that make the original function larger.
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state. - Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols. - Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
- Treat the vault model as local-first across all platforms: - Treat the vault model as local-first across all platforms:
+8 -42
View File
@@ -700,34 +700,7 @@ async function statusForPage(options = {}) {
return refreshPageState(page.tabId, page.url, options); return refreshPageState(page.tabId, page.url, options);
} }
function matchedLoginCredentialRequest(settings, entryId, pageUrl) { async function fillLogin(tabId, entryId) {
return {
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
};
}
function selectedLoginCredentialRequest(settings, entryId) {
return {
action: "get-login",
bearerToken: settings.bearerToken,
entryId
};
}
async function fillMatchedLogin(tabId, entryId) {
const page = await loginFillPage(tabId);
return fillLoginOnPage(tabId, entryId, page.url, matchedLoginCredentialRequest);
}
async function fillSelectedLogin(tabId, entryId) {
const page = await loginFillPage(tabId);
return fillLoginOnPage(tabId, entryId, page.url, selectedLoginCredentialRequest);
}
async function loginFillPage(tabId) {
if (!Number.isInteger(tabId)) { if (!Number.isInteger(tabId)) {
throw new Error("No active tab is available."); throw new Error("No active tab is available.");
} }
@@ -736,10 +709,7 @@ async function loginFillPage(tabId) {
if (!supportsPageStateURL(pageUrl)) { if (!supportsPageStateURL(pageUrl)) {
throw new Error("This page cannot be filled."); throw new Error("This page cannot be filled.");
} }
return { url: pageUrl };
}
async function fillLoginOnPage(tabId, entryId, pageUrl, credentialRequest) {
let state = await getPageState(tabId, pageUrl); let state = await getPageState(tabId, pageUrl);
state = await setPageState(tabId, { state = await setPageState(tabId, {
...state, ...state,
@@ -759,7 +729,12 @@ async function fillLoginOnPage(tabId, entryId, pageUrl, credentialRequest) {
throw new Error("API token is not configured."); throw new Error("API token is not configured.");
} }
const response = await connectNative(credentialRequest(settings, entryId, pageUrl)); const response = await connectNative({
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
});
if (!response?.success || !response.credential) { if (!response?.success || !response.credential) {
throw new Error(response?.error || "KeePassGO did not return a credential."); throw new Error(response?.error || "KeePassGO did not return a credential.");
} }
@@ -871,8 +846,6 @@ const backgroundTestExports = {
shouldContinueWatchingState, shouldContinueWatchingState,
tokenPendingApprovalCount, tokenPendingApprovalCount,
savePlanForObservedLogin, savePlanForObservedLogin,
matchedLoginCredentialRequest,
selectedLoginCredentialRequest,
defaultSettings defaultSettings
}; };
@@ -899,14 +872,7 @@ if (isNodeTestEnv) {
focusTarget: cloneTarget(message.target) focusTarget: cloneTarget(message.target)
}); });
} }
sendResponse({ success: true, ...(await fillMatchedLogin(targetTabID, message.entryId)) }); sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
return;
}
case "keepassgo-fill-selected-entry": {
const targetTabID = Number.isInteger(message?.tabId)
? message.tabId
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
sendResponse({ success: true, ...(await fillSelectedLogin(targetTabID, message.entryId)) });
return; return;
} }
case "keepassgo-load-settings": case "keepassgo-load-settings":
-21
View File
@@ -149,24 +149,3 @@ test("applyBestMatchOnly preserves all matches when disabled", () => {
assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]); assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
}); });
test("matched login credential requests include the page URL for URL validation", () => {
assert.deepEqual(background.matchedLoginCredentialRequest({
bearerToken: "token-1"
}, "vault-console", "https://bellagio.example.invalid/login"), {
action: "get-login",
bearerToken: "token-1",
entryId: "vault-console",
url: "https://bellagio.example.invalid/login"
});
});
test("explicit selected credential requests omit the page URL", () => {
assert.deepEqual(background.selectedLoginCredentialRequest({
bearerToken: "token-1"
}, "no-url-entry"), {
action: "get-login",
bearerToken: "token-1",
entryId: "no-url-entry"
});
});
+2 -15
View File
@@ -97,7 +97,7 @@ function renderMatchList(root, matches, options = {}) {
setStatus("Filled", `${match.title} was sent to the current page.`, "ready"); setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
} }
} catch (error) { } catch (error) {
setStatus(options.errorTitle || (options.onSelect ? "Save failed" : "Fill failed"), error instanceof Error ? error.message : String(error), "error"); setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
} finally { } finally {
row.disabled = false; row.disabled = false;
} }
@@ -147,20 +147,7 @@ function renderSearchResults(results, query) {
return; return;
} }
renderMatchList(root, results, { renderMatchList(root, results, {
emptyMessage: `No entries matched "${query}".`, emptyMessage: `No entries matched "${query}".`
errorTitle: "Fill failed",
onSelect: async (match, targetTabID) => {
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
const result = await runtimeSend({
type: "keepassgo-fill-selected-entry",
entryId: match.id,
tabId: targetTabID
});
if (!result?.success) {
throw new Error(result?.error || "Fill failed.");
}
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
}
}); });
} }
-4
View File
@@ -394,10 +394,6 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page") return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
} }
} }
return s.browserCredential(ctx, token, entry)
}
func (s *Server) browserCredential(ctx context.Context, token apitokens.Token, entry vault.Entry) (*keepassgov1.GetBrowserCredentialResponse, error) {
if strings.TrimSpace(entry.Username) != "" { if strings.TrimSpace(entry.Username) != "" {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil { if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err return nil, err
-34
View File
@@ -693,40 +693,6 @@ func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
} }
} }
func TestVaultServiceGetsExplicitBrowserCredentialWithoutURLMatch(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "no-url-entry",
Title: "Livingston Console",
Username: "livingstondell",
Password: "demo-loop",
Path: []string{"Root", "Heist Crew"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "no-url-entry", Path: []string{"Root", "Heist Crew"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "no-url-entry", Path: []string{"Root", "Heist Crew"}}},
),
},
})
defer cleanup()
resp, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "no-url-entry",
})
if err != nil {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL) error = %v", err)
}
if resp.GetId() != "no-url-entry" {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL).Id = %q, want no-url-entry", resp.GetId())
}
if resp.GetPassword() != "demo-loop" {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL).Password = %q, want demo-loop", resp.GetPassword())
}
}
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) { func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
t.Parallel() t.Parallel()
+127 -1
View File
@@ -249,6 +249,7 @@ type ui struct {
entryFields widget.Editor entryFields widget.Editor
customFieldKeys []widget.Editor customFieldKeys []widget.Editor
customFieldValues []widget.Editor customFieldValues []widget.Editor
copyCustomFields []widget.Clickable
historyIndex widget.Editor historyIndex widget.Editor
groupName widget.Editor groupName widget.Editor
groupParentPath widget.Editor groupParentPath widget.Editor
@@ -402,6 +403,8 @@ type ui struct {
vaultRemoteCredentialClicks []widget.Clickable vaultRemoteCredentialClicks []widget.Clickable
syncRemoteCredentialClicks []widget.Clickable syncRemoteCredentialClicks []widget.Clickable
removeCustomFields []widget.Clickable removeCustomFields []widget.Clickable
toggleCustomFields []widget.Clickable
revealedCustomFields map[string]bool
state appstate.State state appstate.State
visible []entry visible []entry
currentPath []string currentPath []string
@@ -662,6 +665,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
pendingSharedLookupPath: paths.PendingSharedLookupPath, pendingSharedLookupPath: paths.PendingSharedLookupPath,
recentVaultGroups: map[string][]string{}, recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{}, recentVaultUsedAt: map[string]time.Time{},
revealedCustomFields: map[string]bool{},
lifecycleAdvancedHidden: true, lifecycleAdvancedHidden: true,
historyHidden: true, historyHidden: true,
statusBannerTTL: statusBannerDuration, statusBannerTTL: statusBannerDuration,
@@ -940,6 +944,7 @@ func (u *ui) handlePhoneBack() bool {
func (u *ui) resetPasswordPeek() { func (u *ui) resetPasswordPeek() {
u.showPassword = false u.showPassword = false
u.revealedCustomFields = map[string]bool{}
} }
func (u *ui) childGroups() []string { func (u *ui) childGroups() []string {
@@ -2153,6 +2158,12 @@ type detailViewMetrics struct {
cardGap unit.Dp cardGap unit.Dp
} }
type extraStringView struct {
Key string
Value string
Revealed bool
}
func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions { func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions {
rows := u.detailViewRows(item) rows := u.detailViewRows(item)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -2180,6 +2191,8 @@ func (u *ui) detailViewRows(item entry) []layout.Widget {
layout.Spacer{Height: unit.Dp(8)}.Layout, layout.Spacer{Height: unit.Dp(8)}.Layout,
u.detailNotesCard(item), u.detailNotesCard(item),
layout.Spacer{Height: metrics.cardGap}.Layout, layout.Spacer{Height: metrics.cardGap}.Layout,
u.detailExtraStringsCard,
layout.Spacer{Height: metrics.cardGap}.Layout,
u.attachmentSummaryPanel, u.attachmentSummaryPanel,
layout.Spacer{Height: metrics.cardGap}.Layout, layout.Spacer{Height: metrics.cardGap}.Layout,
u.historyPanel, u.historyPanel,
@@ -2339,6 +2352,115 @@ func (u *ui) detailNotesCard(item entry) layout.Widget {
} }
} }
func (u *ui) detailExtraStringsCard(gtx layout.Context) layout.Dimensions {
fields := u.detailExtraStrings()
u.ensureExtraStringClickables(len(fields))
if len(fields) == 0 {
return layout.Dimensions{}
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "EXTRA STRINGS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
}
for i, field := range fields {
index := i
item := field
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.detailExtraStringRow(gtx, index, item)
}))
if i < len(fields)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (u *ui) detailExtraStringRow(gtx layout.Context, index int, field extraStringView) layout.Dimensions {
for u.toggleCustomFields[index].Clicked(gtx) {
u.toggleExtraStringReveal(field.Key)
}
for u.copyCustomFields[index].Clicked(gtx) {
key := field.Key
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction(key) })
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(field.Key))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), field.Value)
return lbl.Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleCustomFields[index], field.Revealed)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.copyCustomFields[index], u.copyIcon, "Copy extra string")
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}),
)
}
func (u *ui) detailExtraStrings() []extraStringView {
item, ok := u.selectedEntry()
if !ok || len(item.Fields) == 0 {
return nil
}
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
slices.Sort(keys)
out := make([]extraStringView, 0, len(keys))
for _, key := range keys {
value := item.Fields[key]
revealed := u.revealedCustomFields[key]
if !revealed {
value = maskedSecretValue(value)
}
out = append(out, extraStringView{Key: key, Value: value, Revealed: revealed})
}
return out
}
func (u *ui) toggleExtraStringReveal(key string) {
if u.revealedCustomFields == nil {
u.revealedCustomFields = map[string]bool{}
}
u.revealedCustomFields[key] = !u.revealedCustomFields[key]
}
func (u *ui) ensureExtraStringClickables(count int) {
if len(u.copyCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions { func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions {
switch u.state.Section { switch u.state.Section {
case appstate.SectionTemplates: case appstate.SectionTemplates:
@@ -2996,7 +3118,11 @@ func (u *ui) detailPasswordValue() string {
if u.showPassword { if u.showPassword {
return item.Password return item.Password
} }
return strings.Repeat("•", max(8, len(item.Password))) return maskedSecretValue(item.Password)
}
func maskedSecretValue(value string) string {
return strings.Repeat("•", max(8, len(value)))
} }
func card(gtx layout.Context, w layout.Widget) layout.Dimensions { func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
+44
View File
@@ -82,6 +82,8 @@ func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil u.customFieldKeys = nil
u.customFieldValues = nil u.customFieldValues = nil
u.removeCustomFields = nil u.removeCustomFields = nil
u.copyCustomFields = nil
u.toggleCustomFields = nil
if len(fields) == 0 { if len(fields) == 0 {
u.appendCustomFieldRow("", "") u.appendCustomFieldRow("", "")
return return
@@ -104,21 +106,53 @@ func (u *ui) appendCustomFieldRow(key, value string) {
u.customFieldKeys = append(u.customFieldKeys, keyEditor) u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor) u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{}) u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
u.copyCustomFields = append(u.copyCustomFields, widget.Clickable{})
u.toggleCustomFields = append(u.toggleCustomFields, widget.Clickable{})
} }
func (u *ui) removeCustomFieldRow(index int) { func (u *ui) removeCustomFieldRow(index int) {
u.ensureCustomFieldRowControls()
if index < 0 || index >= len(u.customFieldKeys) { if index < 0 || index >= len(u.customFieldKeys) {
return return
} }
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...) u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...) u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...) u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
u.copyCustomFields = append(u.copyCustomFields[:index], u.copyCustomFields[index+1:]...)
u.toggleCustomFields = append(u.toggleCustomFields[:index], u.toggleCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 { if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "") u.appendCustomFieldRow("", "")
} }
} }
func (u *ui) ensureCustomFieldRowControls() {
if len(u.customFieldValues) < len(u.customFieldKeys) {
values := make([]widget.Editor, len(u.customFieldKeys))
copy(values, u.customFieldValues)
for i := len(u.customFieldValues); i < len(values); i++ {
values[i] = widget.Editor{SingleLine: true, Submit: false}
}
u.customFieldValues = values
}
if len(u.removeCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.removeCustomFields)
u.removeCustomFields = clicks
}
if len(u.copyCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) currentCustomFields() (map[string]string, error) { func (u *ui) currentCustomFields() (map[string]string, error) {
u.ensureCustomFieldRowControls()
fields := map[string]string{} fields := map[string]string{}
for i := range u.customFieldKeys { for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text()) key := strings.TrimSpace(u.customFieldKeys[i].Text())
@@ -399,6 +433,16 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
return service.Copy(model, u.state.SelectedEntryID, target) return service.Copy(model, u.state.SelectedEntryID, target)
} }
func (u *ui) copySelectedCustomFieldAction(key string) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{Writer: u.clipboardWriter}
return service.CopyCustomField(model, u.state.SelectedEntryID, key)
}
func (u *ui) generatePasswordAction() error { func (u *ui) generatePasswordAction() error {
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text()) profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil { if err != nil {
+1
View File
@@ -667,6 +667,7 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 0 { if len(u.customFieldKeys) == 0 {
u.setCustomFieldRows(nil) u.setCustomFieldRows(nil)
} }
u.ensureCustomFieldRowControls()
return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", 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 {
+85
View File
@@ -3830,6 +3830,21 @@ func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing
} }
} }
func TestUIRemoveCustomFieldRowToleratesMissingClickables(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.customFieldKeys = []widget.Editor{{SingleLine: true}}
u.customFieldValues = []widget.Editor{{SingleLine: true}}
u.removeCustomFields = nil
u.removeCustomFieldRow(0)
if len(u.customFieldKeys) != 1 || len(u.customFieldValues) != 1 || len(u.removeCustomFields) != 1 {
t.Fatalf("custom field rows after remove with missing clickables = %d/%d/%d, want one blank row", len(u.customFieldKeys), len(u.customFieldValues), len(u.removeCustomFields))
}
}
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
t.Parallel() t.Parallel()
@@ -9126,6 +9141,76 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
} }
} }
func TestUIExtraStringValuesAreMaskedUntilRevealed(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
fields := u.detailExtraStrings()
if len(fields) != 1 {
t.Fatalf("len(detailExtraStrings()) = %d, want 1", len(fields))
}
if fields[0].Value != strings.Repeat("•", len("green-light")) {
t.Fatalf("detailExtraStrings()[0].Value hidden = %q, want masked value", fields[0].Value)
}
u.toggleExtraStringReveal("OTPSeed")
fields = u.detailExtraStrings()
if fields[0].Value != "green-light" {
t.Fatalf("detailExtraStrings()[0].Value revealed = %q, want green-light", fields[0].Value)
}
}
func TestUICopyExtraStringWritesClipboardWithoutLeakingStatus(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
writer := &memoryClipboardWriter{}
u.clipboardWriter = writer
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction("OTPSeed") })
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
if u.state.StatusMessage != "copy extra string complete" {
t.Fatalf("state.StatusMessage = %q, want copy extra string complete", u.state.StatusMessage)
}
if strings.Contains(u.state.StatusMessage, "green-light") {
t.Fatalf("state.StatusMessage = %q, must not contain copied extra string value", u.state.StatusMessage)
}
}
func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) { func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) {
t.Parallel() t.Parallel()
+16
View File
@@ -45,6 +45,22 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
return nil return nil
} }
func (s Service) CopyCustomField(model vault.Model, entryID, key string) error {
entry, err := findEntry(model, entryID)
if err != nil {
return err
}
content, ok := entry.Fields[key]
if !ok {
return ErrUnsupportedTarget
}
if err := s.writer().WriteText(content); err != nil {
return writeError{err: err}
}
return nil
}
func (s Service) writer() Writer { func (s Service) writer() Writer {
if s.Writer != nil { if s.Writer != nil {
return s.Writer return s.Writer
+24
View File
@@ -48,6 +48,30 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
} }
} }
func TestServiceCopiesCustomField(t *testing.T) {
t.Parallel()
var writer memoryWriter
service := Service{Writer: &writer}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
}
if err := service.CopyCustomField(model, "vault-console", "OTPSeed"); err != nil {
t.Fatalf("CopyCustomField(vault-console, OTPSeed) error = %v", err)
}
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
}
func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) { func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
t.Parallel() t.Parallel()