From 51e790899643b5b52e10993021df29ad90d0688d Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 20:55:16 -0700 Subject: [PATCH 1/4] refactor(provisioning): split CEL plumbing, rename RuleSource, add port assertion Extract CEL environment construction and ref.Val helpers into cel.go; condition.go keeps the condition lifecycle (compile/cache/match/validate). Rename ResolverOptions.RuleSource to RuleLister to match the authkit.ProvisioningRuleLister port name. Add the missing compile-time PrincipalResolver assertion on *Resolver. Expand the package doc to spell out the wrap-and-conditional-provision contract. Cascade: testkit/internal/authflow/runtime.go updates the struct-literal field name. Co-Authored-By: Claude Opus 4.7 (1M context) --- provisioning/cel.go | 157 +++++++++++++++++++++++++++ provisioning/condition.go | 147 ------------------------- provisioning/doc.go | 13 ++- provisioning/resolver.go | 14 ++- provisioning/resolver_test.go | 6 +- testkit/internal/authflow/runtime.go | 2 +- 6 files changed, 179 insertions(+), 160 deletions(-) create mode 100644 provisioning/cel.go diff --git a/provisioning/cel.go b/provisioning/cel.go new file mode 100644 index 0000000..0213748 --- /dev/null +++ b/provisioning/cel.go @@ -0,0 +1,157 @@ +package provisioning + +import ( + "reflect" + "slices" + "strings" + "sync" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" +) + +const ( + conditionCostLimit uint64 = 10_000 +) + +const ( + conditionInterruptCheckFrequency = 100 +) + +//nolint:gochecknoglobals // CEL environment construction is expensive and intentionally cached process-wide. +var conditionEnvironment struct { + once sync.Once + env *cel.Env + err error +} + +func getConditionEnv() (*cel.Env, error) { + conditionEnvironment.once.Do(func() { + conditionEnvironment.env, conditionEnvironment.err = cel.NewEnv( + cel.Variable("identity", cel.MapType(cel.StringType, cel.StringType)), + cel.Variable("claims", cel.MapType(cel.StringType, cel.DynType)), + cel.Function( + "hasAny", + cel.Overload( + "has_any_dyn_list_string", + []*cel.Type{cel.DynType, cel.ListType(cel.StringType)}, + cel.BoolType, + cel.BinaryBinding(func(value ref.Val, accepted ref.Val) ref.Val { + return types.Bool(valueHasAny(value, acceptedStrings(accepted))) + }), + ), + ), + cel.Function( + "hasToken", + cel.Overload( + "has_token_dyn_string", + []*cel.Type{cel.DynType, cel.StringType}, + cel.BoolType, + cel.BinaryBinding(func(value ref.Val, token ref.Val) ref.Val { + tokenString, ok := stringValue(token) + if !ok || tokenString == "" { + return types.False + } + + return types.Bool(valueHasToken(value, tokenString)) + }), + ), + ), + ) + }) + + return conditionEnvironment.env, conditionEnvironment.err +} + +func acceptedStrings(value ref.Val) map[string]struct{} { + accepted := make(map[string]struct{}) + forEachString(value, func(item string) { + accepted[item] = struct{}{} + }) + + return accepted +} + +func valueHasAny(value ref.Val, accepted map[string]struct{}) bool { + if len(accepted) == 0 { + return false + } + + matched := false + forEachString(value, func(item string) { + if _, ok := accepted[item]; ok { + matched = true + } + }) + + return matched +} + +func valueHasToken(value ref.Val, token string) bool { + matched := false + forEachString(value, func(item string) { + if slices.Contains(strings.Fields(item), token) { + matched = true + } + }) + + return matched +} + +func forEachString(value ref.Val, visit func(string)) { + if item, ok := stringValue(value); ok { + visit(item) + + return + } + + if list, ok := value.(traits.Lister); ok { + iter := list.Iterator() + for iter.HasNext() == types.True { + if item, ok := stringValue(iter.Next()); ok { + visit(item) + } + } + + return + } + + switch native := value.Value().(type) { + case []string: + for _, item := range native { + visit(item) + } + case []any: + for _, item := range native { + if text, ok := item.(string); ok { + visit(text) + } + } + case []ref.Val: + for _, item := range native { + if text, ok := stringValue(item); ok { + visit(text) + } + } + } +} + +func stringValue(value ref.Val) (string, bool) { + if value == nil { + return "", false + } + + if text, ok := value.Value().(string); ok { + return text, true + } + + native, err := value.ConvertToNative(reflect.TypeFor[string]()) + if err != nil { + return "", false + } + text, ok := native.(string) + + return text, ok +} diff --git a/provisioning/condition.go b/provisioning/condition.go index 2de9061..96c765f 100644 --- a/provisioning/condition.go +++ b/provisioning/condition.go @@ -4,15 +4,10 @@ import ( "context" "errors" "fmt" - "reflect" - "slices" "strings" "sync" "github.com/google/cel-go/cel" - "github.com/google/cel-go/common/types" - "github.com/google/cel-go/common/types/ref" - "github.com/google/cel-go/common/types/traits" "github.com/meigma/authkit" ) @@ -20,21 +15,8 @@ import ( const ( // MaxConditionBytes is the maximum UTF-8 byte length accepted for a CEL condition. MaxConditionBytes = 4096 - - conditionCostLimit uint64 = 10_000 -) - -const ( - conditionInterruptCheckFrequency = 100 ) -//nolint:gochecknoglobals // CEL environment construction is expensive and intentionally cached process-wide. -var conditionEnvironment struct { - once sync.Once - env *cel.Env - err error -} - //nolint:gochecknoglobals // Compiled CEL programs are reusable and safe to share across rule evaluations. var conditionProgramCache struct { mu sync.RWMutex @@ -146,132 +128,3 @@ func cacheConditionProgram(condition string, prg cel.Program) cel.Program { return prg } - -func getConditionEnv() (*cel.Env, error) { - conditionEnvironment.once.Do(func() { - conditionEnvironment.env, conditionEnvironment.err = cel.NewEnv( - cel.Variable("identity", cel.MapType(cel.StringType, cel.StringType)), - cel.Variable("claims", cel.MapType(cel.StringType, cel.DynType)), - cel.Function( - "hasAny", - cel.Overload( - "has_any_dyn_list_string", - []*cel.Type{cel.DynType, cel.ListType(cel.StringType)}, - cel.BoolType, - cel.BinaryBinding(func(value ref.Val, accepted ref.Val) ref.Val { - return types.Bool(valueHasAny(value, acceptedStrings(accepted))) - }), - ), - ), - cel.Function( - "hasToken", - cel.Overload( - "has_token_dyn_string", - []*cel.Type{cel.DynType, cel.StringType}, - cel.BoolType, - cel.BinaryBinding(func(value ref.Val, token ref.Val) ref.Val { - tokenString, ok := stringValue(token) - if !ok || tokenString == "" { - return types.False - } - - return types.Bool(valueHasToken(value, tokenString)) - }), - ), - ), - ) - }) - - return conditionEnvironment.env, conditionEnvironment.err -} - -func acceptedStrings(value ref.Val) map[string]struct{} { - accepted := make(map[string]struct{}) - forEachString(value, func(item string) { - accepted[item] = struct{}{} - }) - - return accepted -} - -func valueHasAny(value ref.Val, accepted map[string]struct{}) bool { - if len(accepted) == 0 { - return false - } - - matched := false - forEachString(value, func(item string) { - if _, ok := accepted[item]; ok { - matched = true - } - }) - - return matched -} - -func valueHasToken(value ref.Val, token string) bool { - matched := false - forEachString(value, func(item string) { - if slices.Contains(strings.Fields(item), token) { - matched = true - } - }) - - return matched -} - -func forEachString(value ref.Val, visit func(string)) { - if item, ok := stringValue(value); ok { - visit(item) - - return - } - - if list, ok := value.(traits.Lister); ok { - iter := list.Iterator() - for iter.HasNext() == types.True { - if item, ok := stringValue(iter.Next()); ok { - visit(item) - } - } - - return - } - - switch native := value.Value().(type) { - case []string: - for _, item := range native { - visit(item) - } - case []any: - for _, item := range native { - if text, ok := item.(string); ok { - visit(text) - } - } - case []ref.Val: - for _, item := range native { - if text, ok := stringValue(item); ok { - visit(text) - } - } - } -} - -func stringValue(value ref.Val) (string, bool) { - if value == nil { - return "", false - } - - if text, ok := value.Value().(string); ok { - return text, true - } - - native, err := value.ConvertToNative(reflect.TypeFor[string]()) - if err != nil { - return "", false - } - text, ok := native.(string) - - return text, ok -} diff --git a/provisioning/doc.go b/provisioning/doc.go index cbceabf..f6ad1e0 100644 --- a/provisioning/doc.go +++ b/provisioning/doc.go @@ -1,6 +1,13 @@ // Package provisioning resolves existing principals and can auto-provision allowed identities. // -// Provisioning is an opt-in resolver layer. It does not authenticate credentials. -// Callers decide which verified identities may create principals, and optional -// provisioning rules can assign initial local roles from forwarded claims. +// Provisioning wraps an [authkit.PrincipalResolver] and adds opt-in auto-provisioning behind a +// caller-supplied decision callback. The wrapped resolver is consulted first; only an +// [authkit.ErrUnresolvedIdentity] result triggers the provisioning path. +// +// CEL conditions on optional provisioning rules are compiled, type-checked as boolean +// expressions, and cached process-wide. Matching enabled rules contribute deduplicated initial +// role IDs to the resulting [authkit.ProvisionIdentityRequest]. +// +// Callers decide which verified identities may create principals; the package does not +// authenticate credentials. package provisioning diff --git a/provisioning/resolver.go b/provisioning/resolver.go index 54d403c..8d430f2 100644 --- a/provisioning/resolver.go +++ b/provisioning/resolver.go @@ -25,8 +25,8 @@ type ResolverOptions struct { // Factory maps unresolved identities to principal creation requests. Factory PrincipalFactory - // RuleSource lists provisioning rules used for initial role assignment. - RuleSource authkit.ProvisioningRuleLister + // RuleLister lists provisioning rules used for initial role assignment. + RuleLister authkit.ProvisioningRuleLister } // Resolver resolves linked identities and provisions allowed unresolved identities. @@ -34,9 +34,11 @@ type Resolver struct { resolver authkit.PrincipalResolver provisioner authkit.IdentityProvisioner factory PrincipalFactory - ruleSource authkit.ProvisioningRuleLister + ruleLister authkit.ProvisioningRuleLister } +var _ authkit.PrincipalResolver = (*Resolver)(nil) + // NewResolver constructs an auto-provisioning principal resolver. func NewResolver(opts ResolverOptions) (*Resolver, error) { if opts.Resolver == nil { @@ -53,7 +55,7 @@ func NewResolver(opts ResolverOptions) (*Resolver, error) { resolver: opts.Resolver, provisioner: opts.Provisioner, factory: opts.Factory, - ruleSource: opts.RuleSource, + ruleLister: opts.RuleLister, }, nil } @@ -96,11 +98,11 @@ func (r *Resolver) ResolveIdentity( } func (r *Resolver) initialRoleIDs(ctx context.Context, identity authkit.Identity) ([]string, error) { - if r.ruleSource == nil { + if r.ruleLister == nil { return nil, nil } - rules, err := r.ruleSource.ListProvisioningRules(ctx) + rules, err := r.ruleLister.ListProvisioningRules(ctx) if err != nil { return nil, fmt.Errorf("provisioning: list provisioning rules: %w", err) } diff --git a/provisioning/resolver_test.go b/provisioning/resolver_test.go index f2b578a..36775a1 100644 --- a/provisioning/resolver_test.go +++ b/provisioning/resolver_test.go @@ -143,7 +143,7 @@ func TestResolverAssignsInitialRolesFromProvisioningRules(t *testing.T) { Resolver: inner, Provisioner: provisioner, Factory: allowFactory, - RuleSource: &fakeRuleSource{ + RuleLister: &fakeRuleSource{ rules: []authkit.ProvisioningRule{ { ID: "engineering-readers", @@ -184,14 +184,14 @@ func TestResolverPreservesUnresolvedIdentityWhenFactoryDenies(t *testing.T) { assert.Empty(t, provisioner.requests) } -func TestResolverReturnsRuleSourceError(t *testing.T) { +func TestResolverReturnsRuleListerError(t *testing.T) { ruleErr := errors.New("rules unavailable") provisioner := &fakeProvisioner{} resolver, err := provisioning.NewResolver(provisioning.ResolverOptions{ Resolver: &fakeResolver{err: errUnresolved}, Provisioner: provisioner, Factory: allowFactory, - RuleSource: &fakeRuleSource{err: ruleErr}, + RuleLister: &fakeRuleSource{err: ruleErr}, }) require.NoError(t, err) diff --git a/testkit/internal/authflow/runtime.go b/testkit/internal/authflow/runtime.go index 05feab1..4fb74e5 100644 --- a/testkit/internal/authflow/runtime.go +++ b/testkit/internal/authflow/runtime.go @@ -209,7 +209,7 @@ func NewRuntime(ctx context.Context, store Store, opts ...Option) (*Runtime, err Resolver: store, Provisioner: store, Factory: principalFromIdentity, - RuleSource: store, + RuleLister: store, }) if err != nil { return nil, fmt.Errorf("authflow: create identity exchange resolver: %w", err) From 23ead3abbe1270717403846e58eb4b26306097d9 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 20:57:58 -0700 Subject: [PATCH 2/4] docs(provisioning): godocs on private helpers + safety-gate comments Godoc every private function and module-scope global, replacing the bare nolint justifications on conditionEnvironment and conditionProgramCache with real docs. Annotate the four CEL fail-closed gates (eval error, non-bool runtime output, AST output-type check, CostLimit/InterruptCheckFrequency bounding) and the two provisioning gates in ResolveIdentity (ErrUnresolvedIdentity-only triggers provisioning, factory denial preserves the original unresolved error). Co-Authored-By: Claude Opus 4.7 (1M context) --- provisioning/cel.go | 17 +++++++++++++++++ provisioning/condition.go | 22 ++++++++++++++++++++++ provisioning/resolver.go | 13 ++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/provisioning/cel.go b/provisioning/cel.go index 0213748..6e02ee8 100644 --- a/provisioning/cel.go +++ b/provisioning/cel.go @@ -20,6 +20,10 @@ const ( conditionInterruptCheckFrequency = 100 ) +// conditionEnvironment holds the shared CEL environment. Construction is expensive (declares +// variables, registers hasAny/hasToken overloads) and the environment itself is immutable, so +// the struct is initialised lazily under [sync.Once] and reused across all rule evaluations. +// //nolint:gochecknoglobals // CEL environment construction is expensive and intentionally cached process-wide. var conditionEnvironment struct { once sync.Once @@ -27,6 +31,9 @@ var conditionEnvironment struct { err error } +// getConditionEnv returns the shared CEL environment, building it on first call. Any +// construction error is captured and returned on every subsequent call so callers fail closed +// instead of receiving a half-built environment. func getConditionEnv() (*cel.Env, error) { conditionEnvironment.once.Do(func() { conditionEnvironment.env, conditionEnvironment.err = cel.NewEnv( @@ -65,6 +72,7 @@ func getConditionEnv() (*cel.Env, error) { return conditionEnvironment.env, conditionEnvironment.err } +// acceptedStrings flattens a CEL value into a set of unique strings for set-membership tests. func acceptedStrings(value ref.Val) map[string]struct{} { accepted := make(map[string]struct{}) forEachString(value, func(item string) { @@ -74,6 +82,8 @@ func acceptedStrings(value ref.Val) map[string]struct{} { return accepted } +// valueHasAny reports whether any string drawn from value is present in accepted. Used by the +// hasAny CEL overload to compare a claim against a fixed allow-list. func valueHasAny(value ref.Val, accepted map[string]struct{}) bool { if len(accepted) == 0 { return false @@ -89,6 +99,8 @@ func valueHasAny(value ref.Val, accepted map[string]struct{}) bool { return matched } +// valueHasToken reports whether token appears as a whitespace-delimited word in any string +// drawn from value. Used by the hasToken CEL overload to inspect space-separated scope claims. func valueHasToken(value ref.Val, token string) bool { matched := false forEachString(value, func(item string) { @@ -100,6 +112,9 @@ func valueHasToken(value ref.Val, token string) bool { return matched } +// forEachString invokes visit for every string drawn from value. The three-tier fallback +// (direct string, traits.Lister iteration, reflection over Go native slices) covers the +// distinct shapes a JSON-decoded claim may take inside CEL. func forEachString(value ref.Val, visit func(string)) { if item, ok := stringValue(value); ok { visit(item) @@ -138,6 +153,8 @@ func forEachString(value ref.Val, visit func(string)) { } } +// stringValue converts a CEL ref.Val to a Go string. Returns ("", false) when value is nil or +// cannot be represented as a string by either direct assertion or CEL's native conversion. func stringValue(value ref.Val) (string, bool) { if value == nil { return "", false diff --git a/provisioning/condition.go b/provisioning/condition.go index 96c765f..638983a 100644 --- a/provisioning/condition.go +++ b/provisioning/condition.go @@ -17,6 +17,9 @@ const ( MaxConditionBytes = 4096 ) +// conditionProgramCache memoises compiled CEL programs across rule evaluations. Entries are +// immutable once cached; readers hold the RLock while evaluating against the cached program. +// //nolint:gochecknoglobals // Compiled CEL programs are reusable and safe to share across rule evaluations. var conditionProgramCache struct { mu sync.RWMutex @@ -35,6 +38,9 @@ func ValidateCondition(condition string) error { return err } +// conditionMatches evaluates condition against identity and reports whether the result is true. +// Returns false on any error path (compile, evaluate, non-bool output) so an evaluation failure +// never grants roles. func conditionMatches(ctx context.Context, identity authkit.Identity, condition string) bool { prg, err := compileCondition(condition) if err != nil { @@ -54,14 +60,21 @@ func conditionMatches(ctx context.Context, identity authkit.Identity, condition "claims": claims, }) if err != nil { + // Fail closed when the CEL runtime returns an error (missing claim, cost overrun, + // context cancellation). The rule does not match. return false } result, ok := out.Value().(bool) + // Fail closed when the CEL program produced a value that is not a Go bool. Compile-time + // type-checking should catch this, but the runtime fallback prevents silent grants. return ok && result } +// compileCondition normalises, validates, and compiles condition into a runnable CEL program. +// Successful compilations are cached; cache miss-then-set races resolve to the first cached +// program (see cacheConditionProgram). func compileCondition(condition string) (cel.Program, error) { condition = NormalizeCondition(condition) if condition == "" { @@ -83,10 +96,15 @@ func compileCondition(condition string) (cel.Program, error) { if issues.Err() != nil { return nil, fmt.Errorf("provisioning: compile condition: %w", issues.Err()) } + // Reject non-bool AST output at compile time so conditionMatches' runtime bool assertion + // never has to absorb a type confusion. if !ast.OutputType().IsExactType(cel.BoolType) { return nil, fmt.Errorf("provisioning: condition must produce bool, got %s", ast.OutputType()) } + // CostLimit and InterruptCheckFrequency bound evaluation so a hostile or buggy condition + // cannot run unbounded; OptOptimize folds constants at compile time so the per-call cost + // is closer to the AST shape than the source. prg, err := env.Program( ast, cel.CostLimit(conditionCostLimit), @@ -100,6 +118,7 @@ func compileCondition(condition string) (cel.Program, error) { return cacheConditionProgram(condition, prg), nil } +// cachedConditionProgram returns the cached CEL program for condition if one exists. func cachedConditionProgram(condition string) (cel.Program, bool) { conditionProgramCache.mu.RLock() defer conditionProgramCache.mu.RUnlock() @@ -113,6 +132,9 @@ func cachedConditionProgram(condition string) (cel.Program, bool) { return prg, ok } +// cacheConditionProgram stores prg under condition and returns the canonical cached program. +// Concurrent compilations may race; the first writer wins and later writers return its program +// so all callers share a single cel.Program instance per condition. func cacheConditionProgram(condition string, prg cel.Program) cel.Program { conditionProgramCache.mu.Lock() defer conditionProgramCache.mu.Unlock() diff --git a/provisioning/resolver.go b/provisioning/resolver.go index 8d430f2..3bf6388 100644 --- a/provisioning/resolver.go +++ b/provisioning/resolver.go @@ -68,6 +68,8 @@ func (r *Resolver) ResolveIdentity( if err == nil { return principal, nil } + // Only ErrUnresolvedIdentity triggers provisioning; fail closed on every other resolver + // error so unexpected store failures cannot silently spawn principals. if !errors.Is(err, authkit.ErrUnresolvedIdentity) { return nil, err } @@ -77,6 +79,8 @@ func (r *Resolver) ResolveIdentity( return nil, fmt.Errorf("provisioning: build principal request: %w", factoryErr) } if !ok { + // Factory denied provisioning; return the original unresolved error so callers + // can errors.Is it the same way they would without provisioning enabled. return nil, err } @@ -97,6 +101,8 @@ func (r *Resolver) ResolveIdentity( return &result.Principal, nil } +// initialRoleIDs collects deduplicated role IDs from enabled rules whose condition matches +// identity. Returns (nil, nil) when no RuleLister is configured. func (r *Resolver) initialRoleIDs(ctx context.Context, identity authkit.Identity) ([]string, error) { if r.ruleLister == nil { return nil, nil @@ -110,11 +116,16 @@ func (r *Resolver) initialRoleIDs(ctx context.Context, identity authkit.Identity return matchRules(ctx, identity, rules), nil } -// MatchRules returns local role IDs assigned by provisioning rules for identity. +// MatchRules returns deduplicated local role IDs assigned by enabled provisioning rules whose +// condition matches identity. Evaluation runs against [context.Background]; use within the +// [Resolver] for cancellable evaluation. func MatchRules(identity authkit.Identity, rules []authkit.ProvisioningRule) []string { return matchRules(context.Background(), identity, rules) } +// matchRules applies rules to identity and returns deduplicated role IDs. Rules whose Enabled +// flag is false, whose Provider does not match identity, or whose Condition does not evaluate +// to true are skipped. func matchRules(ctx context.Context, identity authkit.Identity, rules []authkit.ProvisioningRule) []string { if len(rules) == 0 { return nil From ebfa0573dad398f270010d28cac0402bc5824534 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 20:59:54 -0700 Subject: [PATCH 3/4] test(provisioning): split resolver_test.go by concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move test fixtures and hand-rolled fakes into helpers_test.go; move TestMatchRules and TestValidateConditionRejectsInvalidExpressions into rules_test.go; resolver_test.go keeps the constructor and ResolveIdentity cases. Mechanical lift — no behaviour change. Drop TestResolverSatisfiesPrincipalResolver: the production-side var _ authkit.PrincipalResolver = (*Resolver)(nil) assertion added earlier in this PR subsumes it, matching the PR #62/#68 drop precedent. Co-Authored-By: Claude Opus 4.7 (1M context) --- provisioning/helpers_test.go | 119 +++++++++++++++++++ provisioning/resolver_test.go | 210 ---------------------------------- provisioning/rules_test.go | 100 ++++++++++++++++ 3 files changed, 219 insertions(+), 210 deletions(-) create mode 100644 provisioning/helpers_test.go create mode 100644 provisioning/rules_test.go diff --git a/provisioning/helpers_test.go b/provisioning/helpers_test.go new file mode 100644 index 0000000..6a6caba --- /dev/null +++ b/provisioning/helpers_test.go @@ -0,0 +1,119 @@ +package provisioning_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/provisioning" +) + +var errUnresolved = fmt.Errorf("%w: not linked", authkit.ErrUnresolvedIdentity) + +func newResolver( + t *testing.T, + inner authkit.PrincipalResolver, + provisioner authkit.IdentityProvisioner, + factory provisioning.PrincipalFactory, +) *provisioning.Resolver { + t.Helper() + + resolver, err := provisioning.NewResolver(provisioning.ResolverOptions{ + Resolver: inner, + Provisioner: provisioner, + Factory: factory, + }) + require.NoError(t, err) + + return resolver +} + +func allowFactory(context.Context, authkit.Identity) (authkit.CreatePrincipalRequest, bool, error) { + return testPrincipalRequest(), true, nil +} + +func testIdentity() authkit.Identity { + return authkit.Identity{ + Provider: "https://issuer.example", + Subject: "user-123", + Claims: map[string]any{ + "email": "ada@example.test", + "groups": []any{ + "/engineering", + }, + }, + } +} + +func testPrincipalRequest() authkit.CreatePrincipalRequest { + return authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + DisplayName: "Ada Lovelace", + Attributes: map[string]any{ + "email": "ada@example.test", + }, + } +} + +func testPrincipal() authkit.Principal { + return authkit.Principal{ + ID: "principal_1", + Kind: authkit.PrincipalKindUser, + DisplayName: "Ada Lovelace", + Attributes: map[string]any{ + "email": "ada@example.test", + }, + } +} + +type fakeResolver struct { + identities []authkit.Identity + principal *authkit.Principal + err error +} + +func (r *fakeResolver) ResolveIdentity( + _ context.Context, + identity authkit.Identity, +) (*authkit.Principal, error) { + r.identities = append(r.identities, identity) + if r.err != nil { + return nil, r.err + } + + return r.principal, nil +} + +type fakeProvisioner struct { + requests []authkit.ProvisionIdentityRequest + result authkit.ProvisionIdentityResult + err error +} + +func (p *fakeProvisioner) ProvisionIdentity( + _ context.Context, + req authkit.ProvisionIdentityRequest, +) (authkit.ProvisionIdentityResult, error) { + p.requests = append(p.requests, req) + if p.err != nil { + return authkit.ProvisionIdentityResult{}, p.err + } + + return p.result, nil +} + +type fakeRuleSource struct { + rules []authkit.ProvisioningRule + err error +} + +func (s *fakeRuleSource) ListProvisioningRules(context.Context) ([]authkit.ProvisioningRule, error) { + if s.err != nil { + return nil, s.err + } + + return s.rules, nil +} diff --git a/provisioning/resolver_test.go b/provisioning/resolver_test.go index 36775a1..50b844f 100644 --- a/provisioning/resolver_test.go +++ b/provisioning/resolver_test.go @@ -3,8 +3,6 @@ package provisioning_test import ( "context" "errors" - "fmt" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -14,21 +12,6 @@ import ( "github.com/meigma/authkit/provisioning" ) -var errUnresolved = fmt.Errorf("%w: not linked", authkit.ErrUnresolvedIdentity) - -func TestResolverSatisfiesPrincipalResolver(t *testing.T) { - var _ authkit.PrincipalResolver = (*provisioning.Resolver)(nil) - - resolver, err := provisioning.NewResolver(provisioning.ResolverOptions{ - Resolver: &fakeResolver{}, - Provisioner: &fakeProvisioner{}, - Factory: allowFactory, - }) - - require.NoError(t, err) - assert.NotNil(t, resolver) -} - func TestNewResolverValidatesOptions(t *testing.T) { tests := []struct { name string @@ -245,196 +228,3 @@ func TestResolverDoesNotProvisionUnexpectedResolverErrors(t *testing.T) { assert.Nil(t, principal) assert.Empty(t, provisioner.requests) } - -func TestMatchRules(t *testing.T) { - identity := authkit.Identity{ - Provider: "https://token.actions.githubusercontent.com", - Subject: "repo:meigma/imgsrv:ref:refs/heads/main", - CredentialID: "jwt:abc123", - Claims: map[string]any{ - "repository_id": "123456789", - "workflow_ref": "meigma/imgsrv/.github/workflows/publish.yml@refs/heads/main", - "scope": "openid content.write", - "groups": []any{"publishers", "operators"}, - }, - } - rules := []authkit.ProvisioningRule{ - { - ID: "disabled", - Provider: identity.Provider, - Condition: "true", - AssignRoleIDs: []string{"disabled"}, - }, - { - ID: "provider-mismatch", - Provider: "https://other.example", - Condition: "true", - AssignRoleIDs: []string{"other"}, - Enabled: true, - }, - { - ID: "missing-claim", - Provider: identity.Provider, - Condition: `claims.department == "engineering"`, - AssignRoleIDs: []string{"department"}, - Enabled: true, - }, - { - ID: "eval-error", - Provider: identity.Provider, - Condition: `claims.missing.startsWith("repo:")`, - AssignRoleIDs: []string{"eval-error"}, - Enabled: true, - }, - { - ID: "github-main-publisher", - Provider: identity.Provider, - Condition: `identity.subject == "repo:meigma/imgsrv:ref:refs/heads/main" && - claims.repository_id == "123456789" && - claims.workflow_ref == "meigma/imgsrv/.github/workflows/publish.yml@refs/heads/main"`, - AssignRoleIDs: []string{"content-writer"}, - Enabled: true, - }, - { - ID: "scope-match", - Provider: identity.Provider, - Condition: `hasToken(claims.scope, "content.write")`, - AssignRoleIDs: []string{"scope-writer", "content-writer"}, - Enabled: true, - }, - { - ID: "group-match", - Provider: identity.Provider, - Condition: `hasAny(claims.groups, ["publishers"])`, - AssignRoleIDs: []string{"group-writer"}, - Enabled: true, - }, - } - - roleIDs := provisioning.MatchRules(identity, rules) - - assert.Equal(t, []string{"content-writer", "scope-writer", "group-writer"}, roleIDs) -} - -func TestValidateConditionRejectsInvalidExpressions(t *testing.T) { - tests := []struct { - name string - condition string - }{ - {name: "syntax error", condition: "claims.scope =="}, - {name: "type error", condition: "identity.subject"}, - {name: "empty", condition: ""}, - {name: "too large", condition: strings.Repeat("a", provisioning.MaxConditionBytes+1)}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Error(t, provisioning.ValidateCondition(tt.condition)) - }) - } -} - -func newResolver( - t *testing.T, - inner authkit.PrincipalResolver, - provisioner authkit.IdentityProvisioner, - factory provisioning.PrincipalFactory, -) *provisioning.Resolver { - t.Helper() - - resolver, err := provisioning.NewResolver(provisioning.ResolverOptions{ - Resolver: inner, - Provisioner: provisioner, - Factory: factory, - }) - require.NoError(t, err) - - return resolver -} - -func allowFactory(context.Context, authkit.Identity) (authkit.CreatePrincipalRequest, bool, error) { - return testPrincipalRequest(), true, nil -} - -func testIdentity() authkit.Identity { - return authkit.Identity{ - Provider: "https://issuer.example", - Subject: "user-123", - Claims: map[string]any{ - "email": "ada@example.test", - "groups": []any{ - "/engineering", - }, - }, - } -} - -func testPrincipalRequest() authkit.CreatePrincipalRequest { - return authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - DisplayName: "Ada Lovelace", - Attributes: map[string]any{ - "email": "ada@example.test", - }, - } -} - -func testPrincipal() authkit.Principal { - return authkit.Principal{ - ID: "principal_1", - Kind: authkit.PrincipalKindUser, - DisplayName: "Ada Lovelace", - Attributes: map[string]any{ - "email": "ada@example.test", - }, - } -} - -type fakeResolver struct { - identities []authkit.Identity - principal *authkit.Principal - err error -} - -func (r *fakeResolver) ResolveIdentity( - _ context.Context, - identity authkit.Identity, -) (*authkit.Principal, error) { - r.identities = append(r.identities, identity) - if r.err != nil { - return nil, r.err - } - - return r.principal, nil -} - -type fakeProvisioner struct { - requests []authkit.ProvisionIdentityRequest - result authkit.ProvisionIdentityResult - err error -} - -func (p *fakeProvisioner) ProvisionIdentity( - _ context.Context, - req authkit.ProvisionIdentityRequest, -) (authkit.ProvisionIdentityResult, error) { - p.requests = append(p.requests, req) - if p.err != nil { - return authkit.ProvisionIdentityResult{}, p.err - } - - return p.result, nil -} - -type fakeRuleSource struct { - rules []authkit.ProvisioningRule - err error -} - -func (s *fakeRuleSource) ListProvisioningRules(context.Context) ([]authkit.ProvisioningRule, error) { - if s.err != nil { - return nil, s.err - } - - return s.rules, nil -} diff --git a/provisioning/rules_test.go b/provisioning/rules_test.go new file mode 100644 index 0000000..0953135 --- /dev/null +++ b/provisioning/rules_test.go @@ -0,0 +1,100 @@ +package provisioning_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/provisioning" +) + +func TestMatchRules(t *testing.T) { + identity := authkit.Identity{ + Provider: "https://token.actions.githubusercontent.com", + Subject: "repo:meigma/imgsrv:ref:refs/heads/main", + CredentialID: "jwt:abc123", + Claims: map[string]any{ + "repository_id": "123456789", + "workflow_ref": "meigma/imgsrv/.github/workflows/publish.yml@refs/heads/main", + "scope": "openid content.write", + "groups": []any{"publishers", "operators"}, + }, + } + rules := []authkit.ProvisioningRule{ + { + ID: "disabled", + Provider: identity.Provider, + Condition: "true", + AssignRoleIDs: []string{"disabled"}, + }, + { + ID: "provider-mismatch", + Provider: "https://other.example", + Condition: "true", + AssignRoleIDs: []string{"other"}, + Enabled: true, + }, + { + ID: "missing-claim", + Provider: identity.Provider, + Condition: `claims.department == "engineering"`, + AssignRoleIDs: []string{"department"}, + Enabled: true, + }, + { + ID: "eval-error", + Provider: identity.Provider, + Condition: `claims.missing.startsWith("repo:")`, + AssignRoleIDs: []string{"eval-error"}, + Enabled: true, + }, + { + ID: "github-main-publisher", + Provider: identity.Provider, + Condition: `identity.subject == "repo:meigma/imgsrv:ref:refs/heads/main" && + claims.repository_id == "123456789" && + claims.workflow_ref == "meigma/imgsrv/.github/workflows/publish.yml@refs/heads/main"`, + AssignRoleIDs: []string{"content-writer"}, + Enabled: true, + }, + { + ID: "scope-match", + Provider: identity.Provider, + Condition: `hasToken(claims.scope, "content.write")`, + AssignRoleIDs: []string{"scope-writer", "content-writer"}, + Enabled: true, + }, + { + ID: "group-match", + Provider: identity.Provider, + Condition: `hasAny(claims.groups, ["publishers"])`, + AssignRoleIDs: []string{"group-writer"}, + Enabled: true, + }, + } + + roleIDs := provisioning.MatchRules(identity, rules) + + assert.Equal(t, []string{"content-writer", "scope-writer", "group-writer"}, roleIDs) +} + +func TestValidateConditionRejectsInvalidExpressions(t *testing.T) { + tests := []struct { + name string + condition string + }{ + {name: "syntax error", condition: "claims.scope =="}, + {name: "type error", condition: "identity.subject"}, + {name: "empty", condition: ""}, + {name: "too large", condition: strings.Repeat("a", provisioning.MaxConditionBytes+1)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Error(t, provisioning.ValidateCondition(tt.condition)) + }) + } +} From 0cf625939bc106cef490804433a837752586bab1 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 21:01:50 -0700 Subject: [PATCH 4/4] test(provisioning): migrate fakes to mockery Replace fakeResolver, fakeProvisioner, and fakeRuleSource with the authkitmocks.{PrincipalResolver,IdentityProvisioner,ProvisioningRuleLister} constructors that mockery already generates. Each test configures the specific expectation it needs and passes unconfigured mocks for ports its short-circuit path never reaches so stray calls panic rather than silently pass. No .mockery.yaml change; all three entries existed before this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- provisioning/helpers_test.go | 49 ------------ provisioning/resolver_test.go | 145 +++++++++++++++++++--------------- 2 files changed, 82 insertions(+), 112 deletions(-) diff --git a/provisioning/helpers_test.go b/provisioning/helpers_test.go index 6a6caba..80ecd20 100644 --- a/provisioning/helpers_test.go +++ b/provisioning/helpers_test.go @@ -68,52 +68,3 @@ func testPrincipal() authkit.Principal { }, } } - -type fakeResolver struct { - identities []authkit.Identity - principal *authkit.Principal - err error -} - -func (r *fakeResolver) ResolveIdentity( - _ context.Context, - identity authkit.Identity, -) (*authkit.Principal, error) { - r.identities = append(r.identities, identity) - if r.err != nil { - return nil, r.err - } - - return r.principal, nil -} - -type fakeProvisioner struct { - requests []authkit.ProvisionIdentityRequest - result authkit.ProvisionIdentityResult - err error -} - -func (p *fakeProvisioner) ProvisionIdentity( - _ context.Context, - req authkit.ProvisionIdentityRequest, -) (authkit.ProvisionIdentityResult, error) { - p.requests = append(p.requests, req) - if p.err != nil { - return authkit.ProvisionIdentityResult{}, p.err - } - - return p.result, nil -} - -type fakeRuleSource struct { - rules []authkit.ProvisioningRule - err error -} - -func (s *fakeRuleSource) ListProvisioningRules(context.Context) ([]authkit.ProvisioningRule, error) { - if s.err != nil { - return nil, s.err - } - - return s.rules, nil -} diff --git a/provisioning/resolver_test.go b/provisioning/resolver_test.go index 50b844f..fa698ce 100644 --- a/provisioning/resolver_test.go +++ b/provisioning/resolver_test.go @@ -6,43 +6,51 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/meigma/authkit" + authkitmocks "github.com/meigma/authkit/mocks/authkit" "github.com/meigma/authkit/provisioning" ) func TestNewResolverValidatesOptions(t *testing.T) { tests := []struct { name string - opts provisioning.ResolverOptions + opts func(t *testing.T) provisioning.ResolverOptions }{ { name: "missing resolver", - opts: provisioning.ResolverOptions{ - Provisioner: &fakeProvisioner{}, - Factory: allowFactory, + opts: func(t *testing.T) provisioning.ResolverOptions { + return provisioning.ResolverOptions{ + Provisioner: authkitmocks.NewIdentityProvisioner(t), + Factory: allowFactory, + } }, }, { name: "missing provisioner", - opts: provisioning.ResolverOptions{ - Resolver: &fakeResolver{}, - Factory: allowFactory, + opts: func(t *testing.T) provisioning.ResolverOptions { + return provisioning.ResolverOptions{ + Resolver: authkitmocks.NewPrincipalResolver(t), + Factory: allowFactory, + } }, }, { name: "missing factory", - opts: provisioning.ResolverOptions{ - Resolver: &fakeResolver{}, - Provisioner: &fakeProvisioner{}, + opts: func(t *testing.T) provisioning.ResolverOptions { + return provisioning.ResolverOptions{ + Resolver: authkitmocks.NewPrincipalResolver(t), + Provisioner: authkitmocks.NewIdentityProvisioner(t), + } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resolver, err := provisioning.NewResolver(tt.opts) + resolver, err := provisioning.NewResolver(tt.opts(t)) require.Error(t, err) assert.Nil(t, resolver) @@ -52,8 +60,9 @@ func TestNewResolverValidatesOptions(t *testing.T) { func TestResolverReturnsExistingPrincipalWithoutProvisioning(t *testing.T) { existing := testPrincipal() - inner := &fakeResolver{principal: &existing} - provisioner := &fakeProvisioner{} + inner := authkitmocks.NewPrincipalResolver(t) + inner.EXPECT().ResolveIdentity(mock.Anything, testIdentity()).Return(&existing, nil) + provisioner := authkitmocks.NewIdentityProvisioner(t) factoryCalls := 0 resolver := newResolver(t, inner, provisioner, func( context.Context, @@ -69,15 +78,19 @@ func TestResolverReturnsExistingPrincipalWithoutProvisioning(t *testing.T) { require.NoError(t, err) require.NotNil(t, principal) assert.Equal(t, existing, *principal) - assert.Equal(t, []authkit.Identity{testIdentity()}, inner.identities) assert.Equal(t, 0, factoryCalls) - assert.Empty(t, provisioner.requests) } func TestResolverProvisionsAllowedUnresolvedIdentity(t *testing.T) { - inner := &fakeResolver{err: errUnresolved} - provisioner := &fakeProvisioner{ - result: authkit.ProvisionIdentityResult{ + inner := authkitmocks.NewPrincipalResolver(t) + inner.EXPECT().ResolveIdentity(mock.Anything, testIdentity()).Return(nil, errUnresolved) + provisioner := authkitmocks.NewIdentityProvisioner(t) + provisioner.EXPECT(). + ProvisionIdentity(mock.Anything, authkit.ProvisionIdentityRequest{ + Identity: testIdentity(), + Principal: testPrincipalRequest(), + }). + Return(authkit.ProvisionIdentityResult{ Principal: testPrincipal(), Link: authkit.ExternalIdentity{ Provider: testIdentity().Provider, @@ -85,8 +98,7 @@ func TestResolverProvisionsAllowedUnresolvedIdentity(t *testing.T) { PrincipalID: testPrincipal().ID, }, Created: true, - }, - } + }, nil) factoryIdentities := []authkit.Identity{} resolver := newResolver(t, inner, provisioner, func( _ context.Context, @@ -103,16 +115,19 @@ func TestResolverProvisionsAllowedUnresolvedIdentity(t *testing.T) { require.NotNil(t, principal) assert.Equal(t, testPrincipal(), *principal) assert.Equal(t, []authkit.Identity{testIdentity()}, factoryIdentities) - assert.Equal(t, []authkit.ProvisionIdentityRequest{{ - Identity: testIdentity(), - Principal: testPrincipalRequest(), - }}, provisioner.requests) } func TestResolverAssignsInitialRolesFromProvisioningRules(t *testing.T) { - inner := &fakeResolver{err: errUnresolved} - provisioner := &fakeProvisioner{ - result: authkit.ProvisionIdentityResult{ + inner := authkitmocks.NewPrincipalResolver(t) + inner.EXPECT().ResolveIdentity(mock.Anything, testIdentity()).Return(nil, errUnresolved) + provisioner := authkitmocks.NewIdentityProvisioner(t) + provisioner.EXPECT(). + ProvisionIdentity(mock.Anything, authkit.ProvisionIdentityRequest{ + Identity: testIdentity(), + Principal: testPrincipalRequest(), + InitialRoleIDs: []string{"reader"}, + }). + Return(authkit.ProvisionIdentityResult{ Principal: testPrincipal(), Link: authkit.ExternalIdentity{ Provider: testIdentity().Provider, @@ -120,23 +135,22 @@ func TestResolverAssignsInitialRolesFromProvisioningRules(t *testing.T) { PrincipalID: testPrincipal().ID, }, Created: true, + }, nil) + ruleLister := authkitmocks.NewProvisioningRuleLister(t) + ruleLister.EXPECT().ListProvisioningRules(mock.Anything).Return([]authkit.ProvisioningRule{ + { + ID: "engineering-readers", + Provider: testIdentity().Provider, + Condition: `hasAny(claims.groups, ["/engineering"])`, + AssignRoleIDs: []string{"reader"}, + Enabled: true, }, - } + }, nil) resolver, err := provisioning.NewResolver(provisioning.ResolverOptions{ Resolver: inner, Provisioner: provisioner, Factory: allowFactory, - RuleLister: &fakeRuleSource{ - rules: []authkit.ProvisioningRule{ - { - ID: "engineering-readers", - Provider: testIdentity().Provider, - Condition: `hasAny(claims.groups, ["/engineering"])`, - AssignRoleIDs: []string{"reader"}, - Enabled: true, - }, - }, - }, + RuleLister: ruleLister, }) require.NoError(t, err) @@ -144,16 +158,13 @@ func TestResolverAssignsInitialRolesFromProvisioningRules(t *testing.T) { require.NoError(t, err) require.NotNil(t, principal) - assert.Equal(t, []authkit.ProvisionIdentityRequest{{ - Identity: testIdentity(), - Principal: testPrincipalRequest(), - InitialRoleIDs: []string{"reader"}, - }}, provisioner.requests) } func TestResolverPreservesUnresolvedIdentityWhenFactoryDenies(t *testing.T) { - provisioner := &fakeProvisioner{} - resolver := newResolver(t, &fakeResolver{err: errUnresolved}, provisioner, func( + inner := authkitmocks.NewPrincipalResolver(t) + inner.EXPECT().ResolveIdentity(mock.Anything, testIdentity()).Return(nil, errUnresolved) + provisioner := authkitmocks.NewIdentityProvisioner(t) + resolver := newResolver(t, inner, provisioner, func( context.Context, authkit.Identity, ) (authkit.CreatePrincipalRequest, bool, error) { @@ -164,17 +175,20 @@ func TestResolverPreservesUnresolvedIdentityWhenFactoryDenies(t *testing.T) { require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) assert.Nil(t, principal) - assert.Empty(t, provisioner.requests) } func TestResolverReturnsRuleListerError(t *testing.T) { ruleErr := errors.New("rules unavailable") - provisioner := &fakeProvisioner{} + inner := authkitmocks.NewPrincipalResolver(t) + inner.EXPECT().ResolveIdentity(mock.Anything, testIdentity()).Return(nil, errUnresolved) + provisioner := authkitmocks.NewIdentityProvisioner(t) + ruleLister := authkitmocks.NewProvisioningRuleLister(t) + ruleLister.EXPECT().ListProvisioningRules(mock.Anything).Return(nil, ruleErr) resolver, err := provisioning.NewResolver(provisioning.ResolverOptions{ - Resolver: &fakeResolver{err: errUnresolved}, + Resolver: inner, Provisioner: provisioner, Factory: allowFactory, - RuleLister: &fakeRuleSource{err: ruleErr}, + RuleLister: ruleLister, }) require.NoError(t, err) @@ -182,13 +196,14 @@ func TestResolverReturnsRuleListerError(t *testing.T) { require.ErrorIs(t, err, ruleErr) assert.Nil(t, principal) - assert.Empty(t, provisioner.requests) } func TestResolverReturnsFactoryError(t *testing.T) { factoryErr := errors.New("claim mapping failed") - provisioner := &fakeProvisioner{} - resolver := newResolver(t, &fakeResolver{err: errUnresolved}, provisioner, func( + inner := authkitmocks.NewPrincipalResolver(t) + inner.EXPECT().ResolveIdentity(mock.Anything, testIdentity()).Return(nil, errUnresolved) + provisioner := authkitmocks.NewIdentityProvisioner(t) + resolver := newResolver(t, inner, provisioner, func( context.Context, authkit.Identity, ) (authkit.CreatePrincipalRequest, bool, error) { @@ -199,32 +214,36 @@ func TestResolverReturnsFactoryError(t *testing.T) { require.ErrorIs(t, err, factoryErr) assert.Nil(t, principal) - assert.Empty(t, provisioner.requests) } func TestResolverReturnsProvisionerError(t *testing.T) { provisionErr := errors.New("store failed") - provisioner := &fakeProvisioner{err: provisionErr} - resolver := newResolver(t, &fakeResolver{err: errUnresolved}, provisioner, allowFactory) + inner := authkitmocks.NewPrincipalResolver(t) + inner.EXPECT().ResolveIdentity(mock.Anything, testIdentity()).Return(nil, errUnresolved) + provisioner := authkitmocks.NewIdentityProvisioner(t) + provisioner.EXPECT(). + ProvisionIdentity(mock.Anything, authkit.ProvisionIdentityRequest{ + Identity: testIdentity(), + Principal: testPrincipalRequest(), + }). + Return(authkit.ProvisionIdentityResult{}, provisionErr) + resolver := newResolver(t, inner, provisioner, allowFactory) principal, err := resolver.ResolveIdentity(context.Background(), testIdentity()) require.ErrorIs(t, err, provisionErr) assert.Nil(t, principal) - assert.Equal(t, []authkit.ProvisionIdentityRequest{{ - Identity: testIdentity(), - Principal: testPrincipalRequest(), - }}, provisioner.requests) } func TestResolverDoesNotProvisionUnexpectedResolverErrors(t *testing.T) { resolverErr := errors.New("database unavailable") - provisioner := &fakeProvisioner{} - resolver := newResolver(t, &fakeResolver{err: resolverErr}, provisioner, allowFactory) + inner := authkitmocks.NewPrincipalResolver(t) + inner.EXPECT().ResolveIdentity(mock.Anything, testIdentity()).Return(nil, resolverErr) + provisioner := authkitmocks.NewIdentityProvisioner(t) + resolver := newResolver(t, inner, provisioner, allowFactory) principal, err := resolver.ResolveIdentity(context.Background(), testIdentity()) require.ErrorIs(t, err, resolverErr) assert.Nil(t, principal) - assert.Empty(t, provisioner.requests) }