package apiapproval import ( "context" "errors" "slices" "testing" "time" "git.julianfamily.org/keepassgo/internal/apitokens" ) func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) { t.Parallel() broker := NewBroker(time.Minute) broker.now = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) } resultCh := make(chan Result, 1) errCh := make(chan error, 1) go func() { result, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI", ClientName: "grpc-cli"}, apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}) resultCh <- result errCh <- err }() waitForPending(t, broker, 1) pending := broker.Pending() if len(pending) != 1 { t.Fatalf("Pending() len = %d, want 1", len(pending)) } if pending[0].TokenID != "token-1" { t.Fatalf("Pending()[0].TokenID = %q, want token-1", pending[0].TokenID) } if _, _, err := broker.Resolve(pending[0].ID, OutcomeAllowOnce); err != nil { t.Fatalf("Resolve(allow once) error = %v", err) } result := <-resultCh if err := <-errCh; err != nil { t.Fatalf("Request() error = %v, want nil", err) } if result.Outcome != OutcomeAllowOnce { t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeAllowOnce) } if result.Rule != nil { t.Fatalf("Request() rule = %#v, want nil for allow-once", result.Rule) } if got := broker.Pending(); len(got) != 0 { t.Fatalf("Pending() after allow len = %d, want 0", len(got)) } } func TestBrokerReturnsPermanentRuleForDeny(t *testing.T) { t.Parallel() broker := NewBroker(time.Minute) reqDone := make(chan struct{}) var result Result var err error go func() { result, err = broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationReadEntry, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1", Path: []string{"Root", "Internet"}}) close(reqDone) }() waitForPending(t, broker, 1) pending := broker.Pending()[0] request, rule, resolveErr := broker.Resolve(pending.ID, OutcomeDenyPermanent) if resolveErr != nil { t.Fatalf("Resolve(deny permanent) error = %v", resolveErr) } if request.ID != pending.ID { t.Fatalf("Resolve().ID = %q, want %q", request.ID, pending.ID) } if rule == nil || rule.Effect != apitokens.EffectDeny || rule.Operation != apitokens.OperationReadEntry { t.Fatalf("Resolve() rule = %#v, want deny read-entry rule", rule) } <-reqDone if !errors.Is(err, ErrRequestDenied) { t.Fatalf("Request() error = %v, want ErrRequestDenied", err) } if result.Rule == nil || result.Rule.Effect != apitokens.EffectDeny { t.Fatalf("Request() rule = %#v, want deny rule", result.Rule) } if result.Outcome != OutcomeDenyPermanent { t.Fatalf("Request() outcome = %q, want %q", result.Outcome, OutcomeDenyPermanent) } } func TestBrokerSupportsCancellation(t *testing.T) { t.Parallel() broker := NewBroker(time.Minute) errCh := make(chan error, 1) go func() { _, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}) errCh <- err }() waitForPending(t, broker, 1) if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeCancel); err != nil { t.Fatalf("Resolve(cancel) error = %v", err) } if err := <-errCh; !errors.Is(err, ErrRequestCanceled) { t.Fatalf("Request() error = %v, want ErrRequestCanceled", err) } } func TestBrokerTimesOutPendingRequests(t *testing.T) { t.Parallel() broker := NewBroker(10 * time.Millisecond) _, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}) if !errors.Is(err, ErrRequestTimedOut) { t.Fatalf("Request() error = %v, want ErrRequestTimedOut", err) } if got := broker.Pending(); len(got) != 0 { t.Fatalf("Pending() len after timeout = %d, want 0", len(got)) } } func TestNilBrokerReturnsConfigurationError(t *testing.T) { t.Parallel() var broker *Broker _, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}) if !errors.Is(err, ErrBrokerNotConfigured) { t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured) } } func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) { t.Parallel() broker := NewBroker(time.Minute) changes := make(chan int, 4) broker.SetChangeNotifier(func() { changes <- len(broker.Pending()) }) errCh := make(chan error, 1) go func() { _, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}) errCh <- err }() waitForPending(t, broker, 1) if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeAllowOnce); err != nil { t.Fatalf("Resolve(allow once) error = %v", err) } if err := <-errCh; err != nil { t.Fatalf("Request() error = %v, want nil", err) } got := []int{<-changes, <-changes} slices.Sort(got) if !slices.Equal(got, []int{0, 1}) { t.Fatalf("change notifications = %v, want [0 1]", got) } } func waitForPending(t *testing.T, broker *Broker, want int) { t.Helper() deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if got := len(broker.Pending()); got == want { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("Pending() never reached len %d", want) }