Expose API approval prompts to app state and UI
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
)
|
)
|
||||||
@@ -81,8 +83,14 @@ type RemoteOpenableSession interface {
|
|||||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApprovalManager interface {
|
||||||
|
Pending() []apiapproval.Request
|
||||||
|
Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error)
|
||||||
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Session CurrentSession
|
Session CurrentSession
|
||||||
|
Approvals ApprovalManager
|
||||||
Section Section
|
Section Section
|
||||||
CurrentPath []string
|
CurrentPath []string
|
||||||
SearchQuery string
|
SearchQuery string
|
||||||
@@ -92,6 +100,21 @@ type State struct {
|
|||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) PendingApprovals() []apiapproval.Request {
|
||||||
|
if s.Approvals == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.Approvals.Pending()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error {
|
||||||
|
if s.Approvals == nil {
|
||||||
|
return fmt.Errorf("approval manager is not configured")
|
||||||
|
}
|
||||||
|
_, _, err := s.Approvals.Resolve(id, outcome)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) ShowSection(section Section) {
|
func (s *State) ShowSection(section Section) {
|
||||||
s.Section = section
|
s.Section = section
|
||||||
s.CurrentPath = nil
|
s.CurrentPath = nil
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/session"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
@@ -41,6 +43,36 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPendingApprovalsReturnsManagerRequests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Approvals: &stubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{
|
||||||
|
{ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := state.PendingApprovals()
|
||||||
|
if len(got) != 1 || got[0].ID != "approval-1" {
|
||||||
|
t.Fatalf("PendingApprovals() = %#v, want approval-1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveApprovalDelegatesToManager(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manager := &stubApprovalManager{}
|
||||||
|
state := State{Approvals: manager}
|
||||||
|
if err := state.ResolveApproval("approval-1", apiapproval.OutcomeAllowPermanent); err != nil {
|
||||||
|
t.Fatalf("ResolveApproval() error = %v", err)
|
||||||
|
}
|
||||||
|
if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeAllowPermanent {
|
||||||
|
t.Fatalf("ResolveApproval() delegated (%q, %q), want (approval-1, allow-permanent)", manager.lastID, manager.lastOutcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1378,3 +1410,19 @@ func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
|
|||||||
s.changedKey = key
|
s.changedKey = key
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubApprovalManager struct {
|
||||||
|
pending []apiapproval.Request
|
||||||
|
lastID string
|
||||||
|
lastOutcome apiapproval.Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubApprovalManager) Pending() []apiapproval.Request {
|
||||||
|
return append([]apiapproval.Request(nil), s.pending...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||||
|
s.lastID = id
|
||||||
|
s.lastOutcome = outcome
|
||||||
|
return apiapproval.Request{ID: id}, nil, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/appstate"
|
"git.julianfamily.org/keepassgo/appstate"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
@@ -198,6 +200,10 @@ type ui struct {
|
|||||||
showSyncRemote widget.Clickable
|
showSyncRemote widget.Clickable
|
||||||
showSyncPull widget.Clickable
|
showSyncPull widget.Clickable
|
||||||
showSyncPush widget.Clickable
|
showSyncPush widget.Clickable
|
||||||
|
allowApproval widget.Clickable
|
||||||
|
denyApproval widget.Clickable
|
||||||
|
cancelApproval widget.Clickable
|
||||||
|
approvalPermanent widget.Bool
|
||||||
rememberRemoteAuth widget.Bool
|
rememberRemoteAuth widget.Bool
|
||||||
entryClicks []widget.Clickable
|
entryClicks []widget.Clickable
|
||||||
historyClicks []widget.Clickable
|
historyClicks []widget.Clickable
|
||||||
@@ -1480,6 +1486,35 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
for u.showSyncPush.Clicked(gtx) {
|
for u.showSyncPush.Clicked(gtx) {
|
||||||
u.syncDirection = syncDirectionPush
|
u.syncDirection = syncDirectionPush
|
||||||
}
|
}
|
||||||
|
for u.allowApproval.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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for u.denyApproval.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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for u.cancelApproval.Clicked(gtx) {
|
||||||
|
u.runAction("cancel API request", func() error {
|
||||||
|
err := u.resolvePendingApproval(apiapproval.OutcomeCancel)
|
||||||
|
u.approvalPermanent.Value = false
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
for u.lockVault.Clicked(gtx) {
|
for u.lockVault.Clicked(gtx) {
|
||||||
u.runAction("lock vault", u.lockAction)
|
u.runAction("lock vault", u.lockAction)
|
||||||
}
|
}
|
||||||
@@ -1681,6 +1716,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return u.syncDialog(gtx)
|
return u.syncDialog(gtx)
|
||||||
}),
|
}),
|
||||||
|
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if _, ok := u.pendingApproval(); !ok {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return u.approvalDialog(gtx)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1722,6 +1763,81 @@ func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Stack{}.Layout(gtx,
|
||||||
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
paint.FillShape(gtx.Ops, color.NRGBA{A: 110}, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
||||||
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
|
}),
|
||||||
|
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
width := gtx.Dp(unit.Dp(640))
|
||||||
|
if width > gtx.Constraints.Max.X {
|
||||||
|
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
|
||||||
|
}
|
||||||
|
if width < 1 {
|
||||||
|
width = gtx.Constraints.Max.X
|
||||||
|
}
|
||||||
|
gtx.Constraints.Min.X = width
|
||||||
|
gtx.Constraints.Max.X = width
|
||||||
|
return card(gtx, u.approvalDialogContent)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
|
||||||
|
request, ok := u.pendingApproval()
|
||||||
|
if !ok {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceText := approvalResourceText(request)
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(20), "Approve API Request")
|
||||||
|
lbl.Color = accentColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(14), "An external tool requested vault access that is not explicitly allowed or denied.")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return approvalFact(u.theme, "Client", strings.TrimSpace(request.ClientName), strings.TrimSpace(request.TokenName))(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
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,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.allowApproval, "Allow")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.denyApproval, "Deny")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
||||||
return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
@@ -1793,6 +1909,62 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) pendingApproval() (apiapproval.Request, bool) {
|
||||||
|
pending := u.state.PendingApprovals()
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return apiapproval.Request{}, false
|
||||||
|
}
|
||||||
|
return pending[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) resolvePendingApproval(outcome apiapproval.Outcome) error {
|
||||||
|
request, ok := u.pendingApproval()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no pending approval")
|
||||||
|
}
|
||||||
|
return u.state.ResolveApproval(request.ID, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
func approvalResourceText(request apiapproval.Request) string {
|
||||||
|
switch request.Resource.Kind {
|
||||||
|
case apitokens.ResourceEntry:
|
||||||
|
if strings.TrimSpace(request.Resource.EntryID) != "" {
|
||||||
|
return "Entry " + request.Resource.EntryID
|
||||||
|
}
|
||||||
|
case apitokens.ResourceGroup:
|
||||||
|
if len(request.Resource.Path) > 0 {
|
||||||
|
return strings.Join(request.Resource.Path, " / ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Vault root"
|
||||||
|
}
|
||||||
|
|
||||||
|
func approvalFact(theme *material.Theme, title, primary, secondary string) layout.Widget {
|
||||||
|
return 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(theme, unit.Sp(12), strings.ToUpper(title))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(theme, unit.Sp(16), strings.TrimSpace(primary))
|
||||||
|
lbl.Color = theme.Palette.Fg
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if strings.TrimSpace(secondary) == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
lbl := material.Label(theme, unit.Sp(13), strings.TrimSpace(secondary))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
|
func (u *ui) syncPasswordField(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 {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/passwords"
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/session"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
@@ -1937,6 +1939,43 @@ func TestUIShowsLifecycleSetupOnlyBeforeVaultIsOpened(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIPendingApprovalUsesFirstPendingRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.state.Approvals = &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{
|
||||||
|
{ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries},
|
||||||
|
{ID: "approval-2", TokenName: "Browser", Operation: apitokens.OperationReadEntry},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
request, ok := u.pendingApproval()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("pendingApproval() ok = false, want true")
|
||||||
|
}
|
||||||
|
if request.ID != "approval-1" {
|
||||||
|
t.Fatalf("pendingApproval().ID = %q, want approval-1", request.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manager := &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{{ID: "approval-1"}},
|
||||||
|
}
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.state.Approvals = manager
|
||||||
|
|
||||||
|
if err := u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent); err != nil {
|
||||||
|
t.Fatalf("resolvePendingApproval() error = %v", err)
|
||||||
|
}
|
||||||
|
if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeDenyPermanent {
|
||||||
|
t.Fatalf("resolvePendingApproval() delegated (%q, %q), want (approval-1, deny-permanent)", manager.lastID, manager.lastOutcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2991,3 +3030,19 @@ func writeKDBXMainTestFile(t *testing.T, path string, model vault.Model, key vau
|
|||||||
t.Fatalf("WriteFile(%s) error = %v", path, err)
|
t.Fatalf("WriteFile(%s) error = %v", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mainStubApprovalManager struct {
|
||||||
|
pending []apiapproval.Request
|
||||||
|
lastID string
|
||||||
|
lastOutcome apiapproval.Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mainStubApprovalManager) Pending() []apiapproval.Request {
|
||||||
|
return append([]apiapproval.Request(nil), m.pending...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mainStubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||||
|
m.lastID = id
|
||||||
|
m.lastOutcome = outcome
|
||||||
|
return apiapproval.Request{ID: id}, nil, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user