Complete API token authz UI flows

This commit is contained in:
Joe Julian
2026-04-11 00:03:30 -07:00
parent bc226647e1
commit e757be66d9
6 changed files with 354 additions and 235 deletions
+11 -1
View File
@@ -5,6 +5,7 @@ import (
"net"
"testing"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
@@ -19,7 +20,16 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t),
testAPITokenEntry(t,
apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationManageVault,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"Root"},
},
},
),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
+127 -11
View File
@@ -129,7 +129,22 @@ func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
return clicks
}
func (u *ui) ensureAPIPolicyEditClickables(count int) []widget.Clickable {
if count <= 0 {
u.apiPolicyEdits = nil
return nil
}
if len(u.apiPolicyEdits) == count {
return u.apiPolicyEdits
}
clicks := make([]widget.Clickable, count)
copy(clicks, u.apiPolicyEdits)
u.apiPolicyEdits = clicks
return clicks
}
func (u *ui) loadSelectedAPITokenIntoEditor() {
u.selectedAPIPolicyIndex = -1
token, ok := u.selectedAPIToken()
if !ok {
u.apiTokenSecret = ""
@@ -143,6 +158,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.ensureAPIPolicyEditClickables(0)
u.ensureAPIPolicyRemoveClickables(0)
return
}
@@ -154,6 +170,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
u.apiTokenExpiresAt.SetText("")
}
u.apiTokenDisabled.Value = token.Disabled
u.ensureAPIPolicyEditClickables(len(token.Policies))
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
}
@@ -250,14 +267,10 @@ func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
return "", fmt.Errorf("unknown API operation %q", text)
}
func (u *ui) addAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) {
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
if err != nil {
return err
return apitokens.PolicyRule{}, err
}
rule := apitokens.PolicyRule{
Operation: operation,
@@ -270,16 +283,28 @@ func (u *ui) addAPIPolicyRuleAction() error {
if u.apiPolicyGroupScope {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return fmt.Errorf("policy path is required for group scope")
return apitokens.PolicyRule{}, fmt.Errorf("policy path is required for group scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
} else {
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
if entryID == "" {
return fmt.Errorf("entry id is required for entry scope")
return apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
}
return rule, nil
}
func (u *ui) addAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
rule, err := u.apiPolicyRuleFromEditor()
if err != nil {
return err
}
if !uiHasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule)
}
@@ -290,6 +315,63 @@ func (u *ui) addAPIPolicyRuleAction() error {
return nil
}
func (u *ui) editAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("policy index %d out of range", index)
}
rule := token.Policies[index]
u.selectedAPIPolicyIndex = index
u.apiPolicyOperation.SetText(string(rule.Operation))
u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow
if rule.Resource.Kind == apitokens.ResourceEntry {
u.apiPolicyGroupScope = false
u.apiPolicyGroupScopeW.Value = false
u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID))
u.apiPolicyPath.SetText("")
return nil
}
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) saveAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
index := u.selectedAPIPolicyIndex
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("no API policy rule selected")
}
rule, err := u.apiPolicyRuleFromEditor()
if err != nil {
return err
}
for i, existing := range token.Policies {
if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) {
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
}
token.Policies[index] = rule
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiPolicyGroupPathSummary() string {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
@@ -357,6 +439,11 @@ func (u *ui) removeAPIPolicyRuleAction(index int) error {
return nil
}
func (u *ui) cancelAPIPolicyEditAction() error {
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiAuditEvents() []apiaudit.Event {
if u.auditLog == nil {
return nil
@@ -749,8 +836,10 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken()
editClicks := u.ensureAPIPolicyEditClickables(0)
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
if ok {
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
}
rows := []layout.Widget{
@@ -918,6 +1007,10 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &editClicks[index], "Edit")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
}),
@@ -951,15 +1044,23 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
rows = append(rows,
func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions {
actionLabel := "Add Rule"
title := "Policy Composer"
description := "Rules are evaluated per operation. Explicit deny rules override allow rules."
if 0 <= u.selectedAPIPolicyIndex {
actionLabel = "Save Rule"
title = "Policy Editor"
description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer."
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer")
lbl := material.Label(u.theme, unit.Sp(14), title)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
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), "Rules are evaluated per operation. Explicit deny rules override allow rules.")
lbl := material.Label(u.theme, unit.Sp(12), description)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
@@ -1014,7 +1115,22 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if 0 <= u.selectedAPIPolicyIndex {
return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel)
}
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.selectedAPIPolicyIndex < 0 {
return layout.Dimensions{}
}
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit")
})
}),
)
}),
)
})
+30 -14
View File
@@ -364,12 +364,13 @@ type ui struct {
showAutofillApprovalAsk widget.Clickable
showAutofillApprovalAllow widget.Clickable
showAutofillApprovalBlock widget.Clickable
allowApproval widget.Clickable
denyApproval widget.Clickable
allowApprovalOnce widget.Clickable
allowApprovalPermanent widget.Clickable
denyApprovalOnce widget.Clickable
denyApprovalPermanent widget.Clickable
cancelApproval widget.Clickable
cancelLifecycleProgress widget.Clickable
retryLifecycleOpen widget.Clickable
approvalPermanent widget.Bool
syncSetupAutomatic widget.Bool
apiPolicyAllow widget.Bool
apiPolicyGroupScopeW widget.Bool
@@ -381,6 +382,7 @@ type ui struct {
settingsDebugHeaderBounds widget.Bool
entryClicks []widget.Clickable
apiTokenClicks []widget.Clickable
apiPolicyEdits []widget.Clickable
apiPolicyRemoves []widget.Clickable
apiAuditClicks []widget.Clickable
apiAuditTokenFilters []widget.Clickable
@@ -416,6 +418,8 @@ type ui struct {
useSelectedEntryForPolicy widget.Clickable
clearAPIPolicyTarget widget.Clickable
addAPIPolicyRule widget.Clickable
saveAPIPolicyRule widget.Clickable
cancelAPIPolicyEdit widget.Clickable
phoneSplit widget.Float
splitDrag gesture.Drag
splitBase float32
@@ -488,6 +492,7 @@ type ui struct {
entriesState entriesSectionState
deleteGroupPath []string
apiPolicyGroupScope bool
selectedAPIPolicyIndex int
apiTokenSecret string
phoneSyncMenuOrigin image.Point
phoneMainMenuOrigin image.Point
@@ -665,6 +670,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
backgroundResults: make(chan backgroundActionResult, 8),
phoneGroupBrowserExpanded: true,
selectedAPIPolicyIndex: -1,
}
if mode == "phone" {
u.groupControlsHidden = true
@@ -1431,23 +1437,33 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent")
check.Color = accentColor
return check.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.allowApproval, "Allow")
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.allowApprovalOnce, "Allow Once")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.allowApprovalPermanent, "Allow Permanently")
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.denyApproval, "Deny")
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.denyApprovalOnce, "Deny Once")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.denyApprovalPermanent, "Deny Permanently")
}),
)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
}),
+27 -19
View File
@@ -928,33 +928,29 @@ func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) {
}
func (u *ui) handleApprovalClicks(gtx layout.Context) {
for u.allowApproval.Clicked(gtx) {
for u.allowApprovalOnce.Clicked(gtx) {
u.runAction("allow API request", func() error {
outcome := apiapproval.OutcomeAllowOnce
if u.approvalPermanent.Value {
outcome = apiapproval.OutcomeAllowPermanent
}
err := u.resolvePendingApproval(outcome)
u.approvalPermanent.Value = false
return err
return u.resolvePendingApproval(apiapproval.OutcomeAllowOnce)
})
}
for u.denyApproval.Clicked(gtx) {
for u.allowApprovalPermanent.Clicked(gtx) {
u.runAction("allow API request permanently", func() error {
return u.resolvePendingApproval(apiapproval.OutcomeAllowPermanent)
})
}
for u.denyApprovalOnce.Clicked(gtx) {
u.runAction("deny API request", func() error {
outcome := apiapproval.OutcomeDenyOnce
if u.approvalPermanent.Value {
outcome = apiapproval.OutcomeDenyPermanent
}
err := u.resolvePendingApproval(outcome)
u.approvalPermanent.Value = false
return err
return u.resolvePendingApproval(apiapproval.OutcomeDenyOnce)
})
}
for u.denyApprovalPermanent.Clicked(gtx) {
u.runAction("deny API request permanently", func() error {
return u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent)
})
}
for u.cancelApproval.Clicked(gtx) {
u.runAction("cancel API request", func() error {
err := u.resolvePendingApproval(apiapproval.OutcomeCancel)
u.approvalPermanent.Value = false
return err
return u.resolvePendingApproval(apiapproval.OutcomeCancel)
})
}
}
@@ -996,6 +992,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
for u.addAPIPolicyRule.Clicked(gtx) {
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
}
for u.saveAPIPolicyRule.Clicked(gtx) {
u.runAction("save API policy rule", u.saveAPIPolicyRuleAction)
}
for u.cancelAPIPolicyEdit.Clicked(gtx) {
u.runAction("cancel API policy edit", u.cancelAPIPolicyEditAction)
}
for u.useCurrentGroupForPolicy.Clicked(gtx) {
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
}
@@ -1005,6 +1007,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
for u.clearAPIPolicyTarget.Clicked(gtx) {
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
}
for i := range u.apiPolicyEdits {
for u.apiPolicyEdits[i].Clicked(gtx) {
index := i
u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) })
}
}
for i := range u.apiPolicyRemoves {
for u.apiPolicyRemoves[i].Clicked(gtx) {
index := i
+159 -1
View File
@@ -754,6 +754,23 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
}
u.apiTokenName.SetText("Browser Extension Updated")
u.apiTokenClientName.SetText("firefox-desktop")
u.apiTokenExpiresAt.SetText("2026-05-01T00:00:00Z")
if err := u.saveAPITokenAction(); err != nil {
t.Fatalf("saveAPITokenAction() error = %v", err)
}
updated, ok := u.selectedAPIToken()
if !ok {
t.Fatal("selectedAPIToken() ok = false, want true after save")
}
if updated.Name != "Browser Extension Updated" || updated.ClientName != "firefox-desktop" {
t.Fatalf("updated token = %#v, want renamed/firefox-desktop", updated)
}
if updated.ExpiresAt == nil || updated.ExpiresAt.UTC().Format(time.RFC3339) != "2026-05-01T00:00:00Z" {
t.Fatalf("updated.ExpiresAt = %#v, want 2026-05-01T00:00:00Z", updated.ExpiresAt)
}
if err := u.disableAPITokenAction(); err != nil {
t.Fatalf("disableAPITokenAction() error = %v", err)
}
@@ -761,9 +778,17 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
if !ok || !disabled.Disabled {
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
}
if err := u.revokeAPITokenAction(); err != nil {
t.Fatalf("revokeAPITokenAction() error = %v", err)
}
revoked, ok := u.selectedAPIToken()
if !ok || revoked.RevokedAt == nil {
t.Fatalf("selectedAPIToken() = %#v, want revoked token", revoked)
}
}
func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
func TestUIAPITokenPolicyRulesCanBeCreatedUpdatedAndRemoved(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
@@ -799,6 +824,33 @@ func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
}
if len(u.apiPolicyEdits) != 1 {
t.Fatalf("len(apiPolicyEdits) = %d, want 1", len(u.apiPolicyEdits))
}
u.apiPolicyEdits[0].Click()
u.handleAPIPolicyClicks(layout.Context{})
if u.selectedAPIPolicyIndex != 0 {
t.Fatalf("selectedAPIPolicyIndex = %d, want 0 after edit click", u.selectedAPIPolicyIndex)
}
if got := u.apiPolicyPath.Text(); got != "Root / Internet" {
t.Fatalf("apiPolicyPath = %q, want Root / Internet after edit load", got)
}
u.apiPolicyPath.SetText("Root / Security")
u.saveAPIPolicyRule.Click()
u.handleAPIPolicyClicks(layout.Context{})
token, ok = u.selectedAPIToken()
if !ok || len(token.Policies) != 1 {
t.Fatalf("selectedAPIToken().Policies after save = %#v, want 1 rule", token.Policies)
}
if got := strings.Join(token.Policies[0].Resource.Path, " / "); got != "Root / Security" {
t.Fatalf("updated policy path = %q, want Root / Security", got)
}
if u.selectedAPIPolicyIndex != -1 {
t.Fatalf("selectedAPIPolicyIndex after save = %d, want -1", u.selectedAPIPolicyIndex)
}
if err := u.removeAPIPolicyRuleAction(0); err != nil {
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
@@ -4858,6 +4910,112 @@ func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) {
}
}
func TestUIApprovalDialogVisibleForPendingRequest(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.state.Approvals = &mainStubApprovalManager{
pending: []apiapproval.Request{{
ID: "approval-1",
TokenName: "CLI",
ClientName: "grpc-cli",
Operation: apitokens.OperationReadEntry,
Resource: apitokens.Resource{
Kind: apitokens.ResourceEntry,
EntryID: "vault-console",
},
}},
}
dims := u.approvalDialogContent(layout.Context{
Ops: new(op.Ops),
Constraints: layout.Exact(image.Pt(800, 600)),
})
if dims.Size.X == 0 || dims.Size.Y == 0 {
t.Fatalf("approvalDialogContent() = %v, want visible dimensions for pending approval", dims.Size)
}
}
func TestUIApprovalButtonsResolveAllOutcomes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
click func(*ui)
want apiapproval.Outcome
wantMsg string
}{
{
name: "allow once",
click: func(u *ui) {
u.allowApprovalOnce.Click()
},
want: apiapproval.OutcomeAllowOnce,
wantMsg: "allow API request complete",
},
{
name: "allow permanently",
click: func(u *ui) {
u.allowApprovalPermanent.Click()
},
want: apiapproval.OutcomeAllowPermanent,
wantMsg: "allow API request permanently complete",
},
{
name: "deny once",
click: func(u *ui) {
u.denyApprovalOnce.Click()
},
want: apiapproval.OutcomeDenyOnce,
wantMsg: "deny API request complete",
},
{
name: "deny permanently",
click: func(u *ui) {
u.denyApprovalPermanent.Click()
},
want: apiapproval.OutcomeDenyPermanent,
wantMsg: "deny API request permanently complete",
},
{
name: "cancel",
click: func(u *ui) {
u.cancelApproval.Click()
},
want: apiapproval.OutcomeCancel,
wantMsg: "cancel API request complete",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
manager := &mainStubApprovalManager{
pending: []apiapproval.Request{{
ID: "approval-1",
TokenName: "CLI",
ClientName: "grpc-cli",
Operation: apitokens.OperationReadEntry,
}},
}
u := newUIWithModel("desktop", vault.Model{})
u.state.Approvals = manager
tt.click(u)
u.handleApprovalClicks(layout.Context{})
if manager.lastID != "approval-1" || manager.lastOutcome != tt.want {
t.Fatalf("handleApprovalClicks() delegated (%q, %q), want (approval-1, %q)", manager.lastID, manager.lastOutcome, tt.want)
}
if got := u.state.StatusMessage; got != tt.wantMsg {
t.Fatalf("state.StatusMessage = %q, want %q", got, tt.wantMsg)
}
})
}
}
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
t.Parallel()