diff --git a/approver.go b/approver.go new file mode 100644 index 0000000..0f96929 --- /dev/null +++ b/approver.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package bascule + +import ( + "context" + + "go.uber.org/multierr" +) + +// Approver is a strategy for determining if a given token represents +// adequate permissions to access a resource. Approvers are used +// as part of bascule's authorization workflow. +type Approver[R any] interface { + // Approve tests if a given token holds the correct permissions to + // access a given resource. If this method needs to access external + // systems, it should pass the supplied context to honor context + // cancelation semantics. + // + // If this method doesn't support the given token, it should return nil. + Approve(ctx context.Context, resource R, token Token) error +} + +// ApproverFunc is a closure type that implements Approver. +type ApproverFunc[R any] func(context.Context, R, Token) error + +func (af ApproverFunc[R]) Approve(ctx context.Context, resource R, token Token) error { + return af(ctx, resource, token) +} + +// Approvers is an aggregate Approver. +type Approvers[R any] []Approver[R] + +// Append tacks on one or more approvers to this collection. The possibly +// new Approvers instance is returned. The semantics of this method are +// the same as the built-in append. +func (as Approvers[R]) Append(more ...Approver[R]) Approvers[R] { + return append(as, more...) +} + +// Approve requires all approvers in this sequence to allow access. This +// method supplies a logical AND. +// +// Because authorization can be arbitrarily expensive, execution halts at the first failed +// authorization attempt. +func (as Approvers[R]) Approve(ctx context.Context, resource R, token Token) error { + for _, a := range as { + if err := a.Approve(ctx, resource, token); err != nil { + return err + } + } + + return nil +} + +type requireAny[R any] struct { + a Approvers[R] +} + +// Approve returns nil at the first approver that returns nil, i.e. accepts the access. +// Otherwise, this method returns an aggregate error of all the authorization errors. +func (ra requireAny[R]) Approve(ctx context.Context, resource R, token Token) error { + var err error + for _, a := range ra.a { + authErr := a.Approve(ctx, resource, token) + if authErr == nil { + return nil + } + + err = multierr.Append(err, authErr) + } + + return err +} + +// Any returns an Approver which is a logical OR: each approver is executed in +// order, and any approver that allows access results in an immediate return. The +// returned Approver's state is distinct and is unaffected by subsequent changes +// to the Approvers set. +// +// Any error returns from the returned Approver will be an aggregate of all the errors +// returned from each element. +func (as Approvers[R]) Any() Approver[R] { + return requireAny[R]{ + a: append( + make(Approvers[R], 0, len(as)), + as..., + ), + } +} diff --git a/approver_test.go b/approver_test.go new file mode 100644 index 0000000..184f839 --- /dev/null +++ b/approver_test.go @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package bascule + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ApproversTestSuite struct { + TestSuite +} + +func (suite *ApproversTestSuite) TestAuthorize() { + const placeholderResource = "placeholder resource" + approveErr := errors.New("expected Authorize error") + + testCases := []struct { + name string + results []error + expectedErr error + }{ + { + name: "EmptyApprovers", + results: nil, + }, + { + name: "OneSuccess", + results: []error{nil}, + }, + { + name: "OneFailure", + results: []error{approveErr}, + expectedErr: approveErr, + }, + { + name: "FirstFailure", + results: []error{approveErr, errors.New("should not be called")}, + expectedErr: approveErr, + }, + { + name: "MiddleFailure", + results: []error{nil, approveErr, errors.New("should not be called")}, + expectedErr: approveErr, + }, + { + name: "AllSuccess", + results: []error{nil, nil, nil}, + }, + } + + for _, testCase := range testCases { + suite.Run(testCase.name, func() { + var ( + testCtx = suite.testContext() + testToken = suite.testToken() + as Approvers[string] + ) + + for _, err := range testCase.results { + err := err + as = as.Append( + ApproverFunc[string](func(ctx context.Context, resource string, token Token) error { + suite.Same(testCtx, ctx) + suite.Equal(testToken, token) + suite.Equal(placeholderResource, resource) + return err + }), + ) + } + + suite.Equal( + testCase.expectedErr, + as.Approve(testCtx, placeholderResource, testToken), + ) + }) + } +} + +func (suite *ApproversTestSuite) TestAny() { + const placeholderResource = "placeholder resource" + approveErr := errors.New("expected Authorize error") + + testCases := []struct { + name string + results []error + expectedErr error + }{ + { + name: "EmptyApprovers", + results: nil, + }, + { + name: "OneSuccess", + results: []error{nil, errors.New("should not be called")}, + }, + { + name: "OnlyFailure", + results: []error{approveErr}, + expectedErr: approveErr, + }, + { + name: "FirstFailure", + results: []error{approveErr, nil}, + }, + { + name: "LastSuccess", + results: []error{approveErr, approveErr, nil}, + }, + } + + for _, testCase := range testCases { + suite.Run(testCase.name, func() { + var ( + testCtx = suite.testContext() + testToken = suite.testToken() + as Approvers[string] + ) + + for _, err := range testCase.results { + err := err + as = as.Append( + ApproverFunc[string](func(ctx context.Context, resource string, token Token) error { + suite.Same(testCtx, ctx) + suite.Equal(testToken, token) + suite.Equal(placeholderResource, resource) + return err + }), + ) + } + + anyAs := as.Any() + suite.Equal( + testCase.expectedErr, + anyAs.Approve(testCtx, placeholderResource, testToken), + ) + + if len(as) > 0 { + // the any instance should be distinct + as[0] = ApproverFunc[string]( + func(context.Context, string, Token) error { + suite.Fail("should not have been called") + return nil + }, + ) + + suite.Equal( + testCase.expectedErr, + anyAs.Approve(testCtx, placeholderResource, testToken), + ) + } + }) + } +} + +func TestApprovers(t *testing.T) { + suite.Run(t, new(ApproversTestSuite)) +} diff --git a/authenticator.go b/authenticator.go new file mode 100644 index 0000000..185547f --- /dev/null +++ b/authenticator.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package bascule + +import "context" + +// AuthenticateEvent represents the result of bascule's authenticate workflow. +type AuthenticateEvent[S any] struct { + // Source is the object that was parsed to produce the token. + // This field is always set. + Source S + + // Token is the token that resulted from parsing the source. This field + // will only be set if parsing was successful. + Token Token + + // Err is the error that resulted from authentication. This field will be + // nil for a successful authentication. + Err error +} + +// AuthenticatorOption is a configurable option for an Authenticator. +type AuthenticatorOption[S any] interface { + apply(*Authenticator[S]) error +} + +type authenticatorOptionFunc[S any] func(*Authenticator[S]) error + +//nolint:unused +func (aof authenticatorOptionFunc[S]) apply(a *Authenticator[S]) error { return aof(a) } + +// WithAuthenticateListeners adds listeners to the Authenticator being built. +// Multiple calls for this option are cumulative. +func WithAuthenticateListeners[S any](more ...Listener[AuthenticateEvent[S]]) AuthenticatorOption[S] { + return authenticatorOptionFunc[S]( + func(a *Authenticator[S]) error { + a.listeners = a.listeners.Append(more...) + return nil + }, + ) +} + +// WithAuthenticateListenerFuncs is a closure variant of WithAuthenticateListeners. +func WithAuthenticateListenerFuncs[S any](more ...ListenerFunc[AuthenticateEvent[S]]) AuthenticatorOption[S] { + return authenticatorOptionFunc[S]( + func(a *Authenticator[S]) error { + a.listeners = a.listeners.AppendFunc(more...) + return nil + }, + ) +} + +// WithTokenParsers adds token parsers to the Authenticator being built. +// Multiple calls for this option are cumulative. +func WithTokenParsers[S any](more ...TokenParser[S]) AuthenticatorOption[S] { + return authenticatorOptionFunc[S]( + func(a *Authenticator[S]) error { + a.parsers = a.parsers.Append(more...) + return nil + }, + ) +} + +// WithValidators adds validators to the Authenticator being built. +// Multiple calls for this option are cumulative. +func WithValidators[S any](more ...Validator[S]) AuthenticatorOption[S] { + return authenticatorOptionFunc[S]( + func(a *Authenticator[S]) error { + a.validators = a.validators.Append(more...) + return nil + }, + ) +} + +// NewAuthenticator constructs an Authenticator workflow using the supplied options. +// +// At least (1) token parser must be supplied in the options, or this +// function returns ErrNoTokenParsers. +func NewAuthenticator[S any](opts ...AuthenticatorOption[S]) (a *Authenticator[S], err error) { + a = new(Authenticator[S]) + for i := 0; err == nil && i < len(opts); i++ { + err = opts[i].apply(a) + } + + if a.parsers.Len() == 0 { + return nil, ErrNoTokenParsers + } + + return +} + +// Authenticator provides bascule's authentication workflow. This type handles +// parsing tokens, validating them, and dispatching authentication events to listeners. +type Authenticator[S any] struct { + listeners Listeners[AuthenticateEvent[S]] + parsers TokenParsers[S] + validators Validators[S] +} + +// Authenticate implements bascule's authentication pipeline. The following steps are +// performed: +// +// (1) The token is extracted from the source using the configured parser(s) +// (2) The token is validated using any configured validator(s) +// (3) Appropriate events are dispatched to listeners after either of steps (1) or (2) +func (a *Authenticator[S]) Authenticate(ctx context.Context, source S) (token Token, err error) { + token, err = a.parsers.Parse(ctx, source) + if err == nil { + var next Token + next, err = a.validators.Validate(ctx, source, token) + if next != nil { + token = next + } + } + + a.listeners.OnEvent(AuthenticateEvent[S]{ + Source: source, + Token: token, + Err: err, + }) + + return +} diff --git a/authenticator_test.go b/authenticator_test.go new file mode 100644 index 0000000..664c51d --- /dev/null +++ b/authenticator_test.go @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package bascule + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" +) + +type AuthenticatorTestSuite struct { + suite.Suite +} + +// newAuthenticator creates an Authenticator under test, asserting +// that no errors occurred. +func (suite *AuthenticatorTestSuite) newAuthenticator(opts ...AuthenticatorOption[string]) *Authenticator[string] { + a, err := NewAuthenticator(opts...) + suite.Require().NoError(err) + suite.Require().NotNil(a) + return a +} + +func (suite *AuthenticatorTestSuite) newToken() Token { + return StubToken("test") +} + +func (suite *AuthenticatorTestSuite) newCtx() context.Context { + type testContextKey struct{} + return context.WithValue( + context.Background(), + testContextKey{}, + "test value", + ) +} + +func (suite *AuthenticatorTestSuite) newSource() string { + return "test source" +} + +func (suite *AuthenticatorTestSuite) TestNoOptions() { + a, err := NewAuthenticator[string]() + suite.Nil(a) + suite.ErrorIs(err, ErrNoTokenParsers) +} + +func (suite *AuthenticatorTestSuite) TestFullSuccess() { + var ( + expectedCtx = suite.newCtx() + expectedSource = suite.newSource() + expectedToken = suite.newToken() + + parser = new(mockTokenParser[string]) + validator = new(mockValidator[string]) + listener1 = new(mockAuthenticateListener[string]) + listener2 = new(mockAuthenticateListener[string]) + + a = suite.newAuthenticator( + WithTokenParsers(parser), + WithValidators(validator), + WithAuthenticateListeners(listener1), + WithAuthenticateListenerFuncs(listener2.OnEvent), + ) + ) + + parser.ExpectParse(expectedCtx, expectedSource). + Return(expectedToken, error(nil)).Once() + + validator.ExpectValidate(expectedCtx, expectedSource, expectedToken). + Return(Token(nil), error(nil)).Once() + + listener1.ExpectOnEvent(AuthenticateEvent[string]{ + Source: expectedSource, + Token: expectedToken, + Err: nil, + }).Once() + + listener2.ExpectOnEvent(AuthenticateEvent[string]{ + Source: expectedSource, + Token: expectedToken, + Err: nil, + }).Once() + + actualToken, err := a.Authenticate(expectedCtx, expectedSource) + suite.Equal(expectedToken, actualToken) + suite.NoError(err) + + parser.AssertExpectations(suite.T()) + validator.AssertExpectations(suite.T()) + listener1.AssertExpectations(suite.T()) + listener2.AssertExpectations(suite.T()) +} + +func (suite *AuthenticatorTestSuite) TestFullTokenParserFail() { + var ( + expectedCtx = suite.newCtx() + expectedSource = suite.newSource() + expectedErr = errors.New("expected") + + parser = new(mockTokenParser[string]) + validator = new(mockValidator[string]) + listener = new(mockAuthenticateListener[string]) + + a = suite.newAuthenticator( + WithTokenParsers(parser), + WithValidators(validator), + WithAuthenticateListeners(listener), + ) + ) + + parser.ExpectParse(expectedCtx, expectedSource). + Return(Token(nil), expectedErr).Once() + + listener.ExpectOnEvent(AuthenticateEvent[string]{ + Source: expectedSource, + Token: nil, + Err: expectedErr, + }).Once() + + // we don't actually care what is returned for the token + _, err := a.Authenticate(expectedCtx, expectedSource) + suite.ErrorIs(err, expectedErr) + + parser.AssertExpectations(suite.T()) + validator.AssertExpectations(suite.T()) + listener.AssertExpectations(suite.T()) +} + +func (suite *AuthenticatorTestSuite) TestFullValidatorFail() { + var ( + expectedCtx = suite.newCtx() + expectedSource = suite.newSource() + expectedToken = suite.newToken() + expectedErr = errors.New("expected") + + parser = new(mockTokenParser[string]) + validator = new(mockValidator[string]) + listener = new(mockAuthenticateListener[string]) + + a = suite.newAuthenticator( + WithTokenParsers(parser), + WithValidators(validator), + WithAuthenticateListeners(listener), + ) + ) + + parser.ExpectParse(expectedCtx, expectedSource). + Return(expectedToken, error(nil)).Once() + + validator.ExpectValidate(expectedCtx, expectedSource, expectedToken). + Return(Token(nil), expectedErr).Once() + + listener.ExpectOnEvent(AuthenticateEvent[string]{ + Source: expectedSource, + Token: expectedToken, + Err: expectedErr, + }).Once() + + // we don't actually care what is returned for the token + _, err := a.Authenticate(expectedCtx, expectedSource) + suite.ErrorIs(err, expectedErr) + + parser.AssertExpectations(suite.T()) + validator.AssertExpectations(suite.T()) + listener.AssertExpectations(suite.T()) +} + +func TestAuthenticator(t *testing.T) { + suite.Run(t, new(AuthenticatorTestSuite)) +} diff --git a/authorizer.go b/authorizer.go index 54ed63b..977c319 100644 --- a/authorizer.go +++ b/authorizer.go @@ -3,88 +3,99 @@ package bascule -import ( - "context" - - "go.uber.org/multierr" -) - -// Authorizer is a strategy for determining if a given token represents -// adequate permissions to access a resource. -type Authorizer[R any] interface { - // Authorize tests if a given token holds the correct permissions to - // access a given resource. If this method needs to access external - // systems, it should pass the supplied context to honor context - // cancelation semantics. - // - // If this method doesn't support the given token, it should return nil. - Authorize(ctx context.Context, resource R, token Token) error -} +import "context" -// AuthorizerFunc is a closure type that implements Authorizer. -type AuthorizerFunc[R any] func(context.Context, R, Token) error +// AuthorizeEvent represents the result of bascule's authorize workflow. +type AuthorizeEvent[R any] struct { + // Resource is the thing the token wants to access. This + // field is always set. + Resource R -func (af AuthorizerFunc[R]) Authorize(ctx context.Context, resource R, token Token) error { - return af(ctx, resource, token) -} + // Token is the token that either was or was not authorized. + // This field is always set. + Token Token -// Authorizers is a collection of Authorizers. -type Authorizers[R any] []Authorizer[R] + // Err is the error that resulted from authorization. This field will be + // nil for a successful authorization.. + Err error +} -// Append tacks on one or more authorizers to this collection. The possibly -// new Authorizers instance is returned. The semantics of this method are -// the same as the built-in append. -func (as Authorizers[R]) Append(more ...Authorizer[R]) Authorizers[R] { - return append(as, more...) +// AuthorizerOption is a configurable option for an Authorizer. +type AuthorizerOption[S any] interface { + apply(*Authorizer[S]) error } -// Authorize requires all authorizers in this sequence to allow access. This -// method supplies a logical AND. -// -// Because authorization can be arbitrarily expensive, execution halts at the first failed -// authorization attempt. -func (as Authorizers[R]) Authorize(ctx context.Context, resource R, token Token) error { - for _, a := range as { - if err := a.Authorize(ctx, resource, token); err != nil { - return err - } - } +type authorizerOptionFunc[S any] func(*Authorizer[S]) error + +//nolint:unused +func (aof authorizerOptionFunc[S]) apply(a *Authorizer[S]) error { return aof(a) } - return nil +// WithAuthorizeListeners adds listeners to the Authorizer being built. +// Multiple calls for this option are cumulative. +func WithAuthorizeListeners[R any](more ...Listener[AuthorizeEvent[R]]) AuthorizerOption[R] { + return authorizerOptionFunc[R]( + func(a *Authorizer[R]) error { + a.listeners = a.listeners.Append(more...) + return nil + }, + ) } -type requireAny[R any] struct { - a Authorizers[R] +// WithAuthorizeListenerFuncs is a closure variant of WithAuthorizeListeners. +func WithAuthorizeListenerFuncs[R any](more ...ListenerFunc[AuthorizeEvent[R]]) AuthorizerOption[R] { + return authorizerOptionFunc[R]( + func(a *Authorizer[R]) error { + a.listeners = a.listeners.AppendFunc(more...) + return nil + }, + ) } -// Authorize returns nil at the first authorizer that returns nil, i.e. accepts the access. -// Otherwise, this method returns an aggregate error of all the authorization errors. -func (ra requireAny[R]) Authorize(ctx context.Context, resource R, token Token) error { - var err error - for _, a := range ra.a { - authErr := a.Authorize(ctx, resource, token) - if authErr == nil { +// WithApprovers adds approvers to the Authorizer being built. +// Multiple calls for this option are cumulative. +func WithApprovers[R any](more ...Approver[R]) AuthorizerOption[R] { + return authorizerOptionFunc[R]( + func(a *Authorizer[R]) error { + a.approvers = a.approvers.Append(more...) return nil - } + }, + ) +} - err = multierr.Append(err, authErr) +// NewAuthorizer constructs an Authorizer workflow using the supplied options. +// +// If no options are supplied, the returned Authorizer will authorize all tokens +// to access any resources. +func NewAuthorizer[R any](opts ...AuthorizerOption[R]) (a *Authorizer[R], err error) { + a = new(Authorizer[R]) + for i := 0; err == nil && i < len(opts); i++ { + err = opts[i].apply(a) } - return err + return +} + +// Authorizer represents the full bascule authorizer workflow. An authenticated +// token is required as the starting point for authorization. +type Authorizer[R any] struct { + listeners Listeners[AuthorizeEvent[R]] + approvers Approvers[R] } -// Any returns an Authorizer which is a logical OR: each authorizer is executed in -// order, and any authorizer that allows access results in an immediate return. The -// returned Authorizer's state is distinct and is unaffected by subsequent changes -// to the Authorizers set. +// Authorize implements the bascule authorization workflow for a particular type of +// resource. The following steps are performed: // -// Any error returns from the returned Authorizer will be an aggregate of all the errors -// returned from each element. -func (as Authorizers[R]) Any() Authorizer[R] { - return requireAny[R]{ - a: append( - make(Authorizers[R], 0, len(as)), - as..., - ), - } +// (1) Each approver is invoked, and all approvers must approve access +// (2) An AuthorizeEvent is dispatched to any listeners with the result +// +// Any error that occurred during authorization is returned. +func (a *Authorizer[R]) Authorize(ctx context.Context, resource R, token Token) (err error) { + err = a.approvers.Approve(ctx, resource, token) + a.listeners.OnEvent(AuthorizeEvent[R]{ + Resource: resource, + Token: token, + Err: err, + }) + + return } diff --git a/authorizer_test.go b/authorizer_test.go index 7dd9070..bfa688e 100644 --- a/authorizer_test.go +++ b/authorizer_test.go @@ -11,152 +11,165 @@ import ( "github.com/stretchr/testify/suite" ) -type AuthorizersTestSuite struct { - TestSuite +type AuthorizerTestSuite struct { + suite.Suite } -func (suite *AuthorizersTestSuite) TestAuthorize() { - const placeholderResource = "placeholder resource" - authorizeErr := errors.New("expected Authorize error") - - testCases := []struct { - name string - results []error - expectedErr error - }{ - { - name: "EmptyAuthorizers", - results: nil, - }, - { - name: "OneSuccess", - results: []error{nil}, - }, - { - name: "OneFailure", - results: []error{authorizeErr}, - expectedErr: authorizeErr, - }, - { - name: "FirstFailure", - results: []error{authorizeErr, errors.New("should not be called")}, - expectedErr: authorizeErr, - }, - { - name: "MiddleFailure", - results: []error{nil, authorizeErr, errors.New("should not be called")}, - expectedErr: authorizeErr, - }, - { - name: "AllSuccess", - results: []error{nil, nil, nil}, - }, - } - - for _, testCase := range testCases { - suite.Run(testCase.name, func() { - var ( - testCtx = suite.testContext() - testToken = suite.testToken() - as Authorizers[string] - ) - - for _, err := range testCase.results { - err := err - as = as.Append( - AuthorizerFunc[string](func(ctx context.Context, resource string, token Token) error { - suite.Same(testCtx, ctx) - suite.Equal(testToken, token) - suite.Equal(placeholderResource, resource) - return err - }), - ) - } - - suite.Equal( - testCase.expectedErr, - as.Authorize(testCtx, placeholderResource, testToken), - ) - }) - } +// newAuthorizer creates an Authorizer under test, asserting +// that no errors occurred. +func (suite *AuthorizerTestSuite) newAuthorizer(opts ...AuthorizerOption[string]) *Authorizer[string] { + a, err := NewAuthorizer(opts...) + suite.Require().NoError(err) + suite.Require().NotNil(a) + return a } -func (suite *AuthorizersTestSuite) TestAny() { - const placeholderResource = "placeholder resource" - authorizeErr := errors.New("expected Authorize error") - - testCases := []struct { - name string - results []error - expectedErr error - }{ - { - name: "EmptyAuthorizers", - results: nil, - }, - { - name: "OneSuccess", - results: []error{nil, errors.New("should not be called")}, - }, - { - name: "OnlyFailure", - results: []error{authorizeErr}, - expectedErr: authorizeErr, - }, - { - name: "FirstFailure", - results: []error{authorizeErr, nil}, - }, - { - name: "LastSuccess", - results: []error{authorizeErr, authorizeErr, nil}, - }, - } - - for _, testCase := range testCases { - suite.Run(testCase.name, func() { - var ( - testCtx = suite.testContext() - testToken = suite.testToken() - as Authorizers[string] - ) - - for _, err := range testCase.results { - err := err - as = as.Append( - AuthorizerFunc[string](func(ctx context.Context, resource string, token Token) error { - suite.Same(testCtx, ctx) - suite.Equal(testToken, token) - suite.Equal(placeholderResource, resource) - return err - }), - ) - } - - anyAs := as.Any() - suite.Equal( - testCase.expectedErr, - anyAs.Authorize(testCtx, placeholderResource, testToken), - ) - - if len(as) > 0 { - // the any instance should be distinct - as[0] = AuthorizerFunc[string]( - func(context.Context, string, Token) error { - suite.Fail("should not have been called") - return nil - }, - ) - - suite.Equal( - testCase.expectedErr, - anyAs.Authorize(testCtx, placeholderResource, testToken), - ) - } - }) - } +func (suite *AuthorizerTestSuite) newToken() Token { + return StubToken("test") } -func TestAuthorizers(t *testing.T) { - suite.Run(t, new(AuthorizersTestSuite)) +func (suite *AuthorizerTestSuite) newCtx() context.Context { + type testContextKey struct{} + return context.WithValue( + context.Background(), + testContextKey{}, + "test value", + ) +} + +func (suite *AuthorizerTestSuite) newResource() string { + return "test resource" +} + +func (suite *AuthorizerTestSuite) TestNoOptions() { + a := suite.newAuthorizer() + + err := a.Authorize( + suite.newCtx(), + suite.newResource(), + suite.newToken(), + ) + + suite.NoError(err) +} + +func (suite *AuthorizerTestSuite) TestFullSuccess() { + var ( + expectedCtx = suite.newCtx() + expectedResource = suite.newResource() + expectedToken = suite.newToken() + + approver1 = new(mockApprover[string]) + approver2 = new(mockApprover[string]) + + listener1 = new(mockAuthorizeListener[string]) + listener2 = new(mockAuthorizeListener[string]) + + a = suite.newAuthorizer( + WithApprovers(approver1, approver2), + WithAuthorizeListeners(listener1), + WithAuthorizeListenerFuncs(listener2.OnEvent), + ) + ) + + approver1.ExpectApprove(expectedCtx, expectedResource, expectedToken). + Return(nil).Once() + approver2.ExpectApprove(expectedCtx, expectedResource, expectedToken). + Return(nil).Once() + + listener1.ExpectOnEvent(AuthorizeEvent[string]{ + Resource: expectedResource, + Token: expectedToken, + Err: nil, + }) + + listener2.ExpectOnEvent(AuthorizeEvent[string]{ + Resource: expectedResource, + Token: expectedToken, + Err: nil, + }) + + err := a.Authorize(expectedCtx, expectedResource, expectedToken) + suite.NoError(err) + + listener1.AssertExpectations(suite.T()) + listener2.AssertExpectations(suite.T()) + approver1.AssertExpectations(suite.T()) + approver2.AssertExpectations(suite.T()) +} + +func (suite *AuthorizerTestSuite) TestFullFirstApproverFail() { + var ( + expectedCtx = suite.newCtx() + expectedResource = suite.newResource() + expectedToken = suite.newToken() + expectedErr = errors.New("expected") + + approver1 = new(mockApprover[string]) + approver2 = new(mockApprover[string]) + + listener = new(mockAuthorizeListener[string]) + + a = suite.newAuthorizer( + WithApprovers(approver1, approver2), + WithAuthorizeListeners(listener), + ) + ) + + approver1.ExpectApprove(expectedCtx, expectedResource, expectedToken). + Return(expectedErr).Once() + + listener.ExpectOnEvent(AuthorizeEvent[string]{ + Resource: expectedResource, + Token: expectedToken, + Err: expectedErr, + }) + + err := a.Authorize(expectedCtx, expectedResource, expectedToken) + suite.ErrorIs(err, expectedErr) + + listener.AssertExpectations(suite.T()) + approver1.AssertExpectations(suite.T()) + approver2.AssertExpectations(suite.T()) +} + +func (suite *AuthorizerTestSuite) TestFullSecondApproverFail() { + var ( + expectedCtx = suite.newCtx() + expectedResource = suite.newResource() + expectedToken = suite.newToken() + expectedErr = errors.New("expected") + + approver1 = new(mockApprover[string]) + approver2 = new(mockApprover[string]) + + listener = new(mockAuthorizeListener[string]) + + a = suite.newAuthorizer( + WithApprovers(approver1, approver2), + WithAuthorizeListeners(listener), + ) + ) + + approver1.ExpectApprove(expectedCtx, expectedResource, expectedToken). + Return(nil).Once() + approver2.ExpectApprove(expectedCtx, expectedResource, expectedToken). + Return(expectedErr).Once() + + listener.ExpectOnEvent(AuthorizeEvent[string]{ + Resource: expectedResource, + Token: expectedToken, + Err: expectedErr, + }) + + err := a.Authorize(expectedCtx, expectedResource, expectedToken) + suite.ErrorIs(err, expectedErr) + + listener.AssertExpectations(suite.T()) + approver1.AssertExpectations(suite.T()) + approver2.AssertExpectations(suite.T()) +} + +func TestAuthorizer(t *testing.T) { + suite.Run(t, new(AuthorizerTestSuite)) } diff --git a/basculehttp/middleware.go b/basculehttp/middleware.go index b59e32f..c2cf69b 100644 --- a/basculehttp/middleware.go +++ b/basculehttp/middleware.go @@ -4,7 +4,6 @@ package basculehttp import ( - "context" "net/http" "strconv" @@ -23,22 +22,23 @@ func (mof middlewareOptionFunc) apply(m *Middleware) error { return mof(m) } -// WithTokenParsers appends token parsers to the chain used by the middleware. -// Each invocation of this option is cumulative. Token parsers are run in the -// order supplied via this option. -func WithTokenParsers(tps ...bascule.TokenParser[*http.Request]) MiddlewareOption { +// WithAuthenticator supplies the Authenticator workflow for the middleware. +// +// If no authenticator is supplied, NewMiddeware returns an error. +func WithAuthenticator(authenticator *bascule.Authenticator[*http.Request]) MiddlewareOption { return middlewareOptionFunc(func(m *Middleware) error { - m.tokenParsers = m.tokenParsers.Append(tps...) + m.authenticator = authenticator return nil }) } -// WithAuthentication adds validators used for authentication to this Middleware. Each -// invocation of this option is cumulative. Authentication validators are run in the order -// supplied by this option. -func WithAuthentication(v ...bascule.Validator[*http.Request]) MiddlewareOption { +// WithAuthorizer supplies the Authorizer workflow for the middleware. +// +// The Authorizer is optional. If no authorizer is supplied, then no authorization +// takes place and no authorization events are fired. +func WithAuthorizer(authorizer *bascule.Authorizer[*http.Request]) MiddlewareOption { return middlewareOptionFunc(func(m *Middleware) error { - m.authentication = m.authentication.Append(v...) + m.authorizer = authorizer return nil }) } @@ -53,20 +53,6 @@ func WithChallenges(ch ...Challenge) MiddlewareOption { }) } -// WithAuthorization adds authorizers to this Middleware. Each invocation of this option -// is cumulative. Authorizers are executed for each request in the order supplied -// via this option. -// -// A Middleware requires all its options to pass in order to allow access. Callers can -// use Authorizers.Any to create authorizers that require only (1) authorizer to pass. -// This is useful for use cases like admin access or alternate capabilities. -func WithAuthorization(a ...bascule.Authorizer[*http.Request]) MiddlewareOption { - return middlewareOptionFunc(func(m *Middleware) error { - m.authorization = m.authorization.Append(a...) - return nil - }) -} - // WithErrorStatusCoder sets the strategy used to write errors to HTTP responses. If this // option is omitted or if esc is nil, DefaultErrorStatusCoder is used. func WithErrorStatusCoder(esc ErrorStatusCoder) MiddlewareOption { @@ -97,10 +83,9 @@ func WithErrorMarshaler(em ErrorMarshaler) MiddlewareOption { // Middleware is an immutable configuration that can decorate multiple handlers. type Middleware struct { - tokenParsers bascule.TokenParsers[*http.Request] - authentication bascule.Validators[*http.Request] - authorization bascule.Authorizers[*http.Request] - challenges Challenges + authenticator *bascule.Authenticator[*http.Request] + authorizer *bascule.Authorizer[*http.Request] + challenges Challenges errorStatusCoder ErrorStatusCoder errorMarshaler ErrorMarshaler @@ -171,18 +156,6 @@ func (m *Middleware) writeError(response http.ResponseWriter, request *http.Requ } } -func (m *Middleware) parseToken(ctx context.Context, request *http.Request) (bascule.Token, error) { - return m.tokenParsers.Parse(ctx, request) -} - -func (m *Middleware) authenticate(ctx context.Context, request *http.Request, token bascule.Token) (bascule.Token, error) { - return m.authentication.Validate(ctx, request, token) -} - -func (m *Middleware) authorize(ctx context.Context, token bascule.Token, request *http.Request) error { - return m.authorization.Authorize(ctx, request, token) -} - // frontDoor is the internal handler implementation that protects a handler // using the bascule workflow. type frontDoor struct { @@ -193,28 +166,22 @@ type frontDoor struct { // ServeHTTP implements the bascule workflow, using the configured middleware. func (fd *frontDoor) ServeHTTP(response http.ResponseWriter, request *http.Request) { ctx := request.Context() - token, err := fd.parseToken(ctx, request) + token, err := fd.authenticator.Authenticate(ctx, request) if err != nil { // by default, failing to parse a token is a malformed request fd.writeError(response, request, http.StatusBadRequest, err) return } - token, err = fd.authenticate(ctx, request, token) - if err != nil { - // at this point in the workflow, the request has valid credentials. we use - // StatusForbidden as the default because any failure to authenticate isn't a - // case where the caller needs to supply credentials. Rather, the supplied - // credentials aren't adequate enough. - fd.writeError(response, request, http.StatusForbidden, err) - return - } - ctx = bascule.WithToken(ctx, token) - err = fd.authorize(ctx, token, request) - if err != nil { - fd.writeError(response, request, http.StatusForbidden, err) - return + + // the authorizer is optional + if fd.authorizer != nil { + err = fd.authorizer.Authorize(ctx, request, token) + if err != nil { + fd.writeError(response, request, http.StatusForbidden, err) + return + } } fd.protected.ServeHTTP(response, request.WithContext(ctx)) diff --git a/basculehttp/middleware_examples_test.go b/basculehttp/middleware_examples_test.go index 597e9d4..240713d 100644 --- a/basculehttp/middleware_examples_test.go +++ b/basculehttp/middleware_examples_test.go @@ -14,16 +14,16 @@ import ( // ExampleMiddleware_basicauth illustrates how to use a basculehttp Middleware with // just basic auth. func ExampleMiddleware_basicauth() { - tp, err := NewAuthorizationParser( + tp, _ := NewAuthorizationParser( WithScheme(SchemeBasic, BasicTokenParser{}), ) - if err != nil { - panic(err) - } + a, _ := bascule.NewAuthenticator( + bascule.WithTokenParsers(tp), + ) m, err := NewMiddleware( - WithTokenParsers(tp), + WithAuthenticator(a), ) if err != nil { diff --git a/eventType_string.go b/eventType_string.go deleted file mode 100644 index ff915b8..0000000 --- a/eventType_string.go +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated by "stringer -type=EventType -trimprefix=EventType -output=eventType_string.go"; DO NOT EDIT. - -package bascule - -import "strconv" - -func _() { - // An "invalid array index" compiler error signifies that the constant values have changed. - // Re-run the stringer command to generate them again. - var x [1]struct{} - _ = x[EventTypeSuccess-0] - _ = x[EventTypeMissingCredentials-1] - _ = x[EventTypeInvalidCredentials-2] - _ = x[EventTypeFailedAuthentication-3] - _ = x[EventTypeFailedAuthorization-4] -} - -const _EventType_name = "SuccessMissingCredentialsInvalidCredentialsFailedAuthenticationFailedAuthorization" - -var _EventType_index = [...]uint8{0, 7, 25, 43, 63, 82} - -func (i EventType) String() string { - if i < 0 || i >= EventType(len(_EventType_index)-1) { - return "EventType(" + strconv.FormatInt(int64(i), 10) + ")" - } - return _EventType_name[_EventType_index[i]:_EventType_index[i+1]] -} diff --git a/listener.go b/listener.go index c64deca..0c4f516 100644 --- a/listener.go +++ b/listener.go @@ -3,90 +3,31 @@ package bascule -//go:generate stringer -type=EventType -trimprefix=EventType -output=eventType_string.go - -// EventType describes the kind of event bascule has dispatched. -type EventType int - -const ( - // EventTypeSuccess represents a completely successful authorization. - // The event's Token field will be set to resulting token. - EventTypeSuccess EventType = iota - - // EventTypeMissingCredentials represents a source that missing any - // kind of credentials that are recognizable to the way bascule - // has been configured. - EventTypeMissingCredentials - - // EventTypeInvalidCredentials indicates that credentials were present - // in the source, but were unparseable. - EventTypeInvalidCredentials - - // EventTypeFailedAuthentication indicates that valid, parseable credentials - // were present in the source, but the token failed authentication. - // - // The Token field will be set in the Event. - EventTypeFailedAuthentication - - // EventTypeFailedAuthorization indicates that valid, parseable credentials - // were present in the source and that the resulting token was authentic. - // However, the token did not have access to the resource(s) being requested. - // - // The Token field will be set in the Event. - EventTypeFailedAuthorization -) - -// Event holds information about the result of a attempting to obtain a token. -type Event[S any] struct { - // Type is the kind of event. This field is always set. - Type EventType - - // Source is the source of credentials. This field is always set. - Source S - - // Token is the parsed token from the source. This field will always be set - // if the token successfully parsed, even if it wasn't authentic or authorized. - Token Token - - // Err is any error that occurred from the bascule infrastructure. This will - // always be nil for a successful Event. This field MAY BE set if the - // configured infrastructure gave more information about why the attempt to - // get a token failed. - Err error -} - -// Success is a convenience for checking if the event's Type field represents -// a successful token. Using this method ensures that client code will work -// in future versions. -func (e Event[S]) Success() bool { - return e.Type == EventTypeSuccess -} - // Listener is a sink for bascule events. -type Listener[S any] interface { +type Listener[E any] interface { // OnEvent receives a bascule event. This method must not block or panic. - OnEvent(Event[S]) + OnEvent(E) } // ListenerFunc is a closure that can act as a Listener. -type ListenerFunc[S any] func(Event[S]) +type ListenerFunc[E any] func(E) // OnEvent satisfies the Listener interface. -func (lf ListenerFunc[S]) OnEvent(e Event[S]) { lf(e) } +func (lf ListenerFunc[E]) OnEvent(e E) { lf(e) } // Listeners is an aggregate Listener. -type Listeners[S any] []Listener[S] +type Listeners[E any] []Listener[E] // Append adds more listeners to this aggregate. The (possibly new) // aggregate Listeners is returned. This method has the same // semantics as the built-in append. -func (ls Listeners[S]) Append(more ...Listener[S]) Listeners[S] { +func (ls Listeners[E]) Append(more ...Listener[E]) Listeners[E] { return append(ls, more...) } // AppendFunc is a more convenient version of Append when using // closures as listeners. -func (ls Listeners[S]) AppendFunc(more ...ListenerFunc[S]) Listeners[S] { +func (ls Listeners[E]) AppendFunc(more ...ListenerFunc[E]) Listeners[E] { for _, lf := range more { if lf != nil { // handle the nil interface case ls = ls.Append(lf) @@ -98,7 +39,7 @@ func (ls Listeners[S]) AppendFunc(more ...ListenerFunc[S]) Listeners[S] { // OnEvent dispatches the given event to all listeners // contained by this aggregate. -func (ls Listeners[S]) OnEvent(e Event[S]) { +func (ls Listeners[E]) OnEvent(e E) { for _, l := range ls { l.OnEvent(e) } diff --git a/listener_test.go b/listener_test.go index fca5a82..4d6ffee 100644 --- a/listener_test.go +++ b/listener_test.go @@ -15,83 +15,36 @@ type ListenerTestSuite struct { suite.Suite } -func (suite *ListenerTestSuite) TestEvent() { - suite.Run("Success", func() { - testCases := []struct { - name string - event Event[int] - expected bool - }{ - { - name: "type success", - event: Event[int]{ - Type: EventTypeSuccess, - }, - expected: true, - }, - { - name: "type missing credentials", - event: Event[int]{ - Type: EventTypeMissingCredentials, - }, - expected: false, - }, - } - - for _, testCase := range testCases { - suite.Run(testCase.name, func() { - suite.Equal( - testCase.expected, - testCase.event.Success(), - ) - }) - } - }) - - // just check that strings are unique - suite.Run("String", func() { - var ( - eventTypes = []EventType{ - EventTypeSuccess, - EventTypeMissingCredentials, - EventTypeInvalidCredentials, - EventTypeFailedAuthentication, - EventTypeFailedAuthorization, - EventType(256), // random weird value should still work - } - - strings = make(map[string]bool) - ) - - for _, et := range eventTypes { - strings[et.String()] = true - } - - suite.Equal(len(eventTypes), len(strings)) - }) +func (suite *ListenerTestSuite) newTestEvent() AuthenticateEvent[int] { + return AuthenticateEvent[int]{ + Source: 2349732, + Token: StubToken("test token"), + Err: errors.New("expected"), + } } func (suite *ListenerTestSuite) TestListenerFunc() { var ( called bool - l Listener[int] = ListenerFunc[int](func(e Event[int]) { - suite.Equal(EventTypeMissingCredentials, e.Type) - called = true - }) - ) + expectedEvent = suite.newTestEvent() - l.OnEvent(Event[int]{ - Type: EventTypeMissingCredentials, - }) + l Listener[AuthenticateEvent[int]] = ListenerFunc[AuthenticateEvent[int]]( + func(actualEvent AuthenticateEvent[int]) { + suite.Equal(expectedEvent, actualEvent) + called = true + }, + ) + ) + l.OnEvent(expectedEvent) suite.True(called) } func (suite *ListenerTestSuite) TestListeners() { suite.Run("Empty", func() { - var ls Listeners[int] - ls.OnEvent(Event[int]{}) // should be fine + var ls Listeners[AuthenticateEvent[int]] + ls.OnEvent(AuthenticateEvent[int]{}) // should be fine }) suite.Run("Append", func() { @@ -99,20 +52,18 @@ func (suite *ListenerTestSuite) TestListeners() { suite.Run(fmt.Sprintf("count=%d", count), func() { var ( called int - expectedEvent = Event[int]{ - Type: EventTypeInvalidCredentials, - Source: 1234, - Err: errors.New("expected"), - } + expectedEvent = suite.newTestEvent() - ls Listeners[int] + ls Listeners[AuthenticateEvent[int]] ) for i := 0; i < count; i++ { - var l Listener[int] = ListenerFunc[int](func(e Event[int]) { - suite.Equal(expectedEvent, e) - called++ - }) + var l Listener[AuthenticateEvent[int]] = ListenerFunc[AuthenticateEvent[int]]( + func(actualEvent AuthenticateEvent[int]) { + suite.Equal(expectedEvent, actualEvent) + called++ + }, + ) ls = ls.Append(l) } @@ -128,17 +79,13 @@ func (suite *ListenerTestSuite) TestListeners() { suite.Run(fmt.Sprintf("count=%d", count), func() { var ( called int - expectedEvent = Event[int]{ - Type: EventTypeInvalidCredentials, - Source: 1234, - Err: errors.New("expected"), - } + expectedEvent = suite.newTestEvent() - ls Listeners[int] + ls Listeners[AuthenticateEvent[int]] ) for i := 0; i < count; i++ { - ls = ls.AppendFunc(func(e Event[int]) { + ls = ls.AppendFunc(func(e AuthenticateEvent[int]) { suite.Equal(expectedEvent, e) called++ }) diff --git a/mocks_test.go b/mocks_test.go index f313a29..c9bd335 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -9,10 +9,6 @@ import ( "github.com/stretchr/testify/mock" ) -type stubToken string - -func (t stubToken) Principal() string { return string(t) } - type mockToken struct { mock.Mock } @@ -82,3 +78,39 @@ func assertTokenParsers[S any](t mock.TestingT, tps ...TokenParser[S]) (passed b return } + +type mockApprover[R any] struct { + mock.Mock +} + +func (m *mockApprover[R]) Approve(ctx context.Context, resource R, token Token) error { + return m.Called(ctx, resource, token).Error(0) +} + +func (m *mockApprover[R]) ExpectApprove(ctx context.Context, resource R, token Token) *mock.Call { + return m.On("Approve", ctx, resource, token) +} + +type mockAuthenticateListener[E any] struct { + mock.Mock +} + +func (m *mockAuthenticateListener[E]) OnEvent(e AuthenticateEvent[E]) { + m.Called(e) +} + +func (m *mockAuthenticateListener[E]) ExpectOnEvent(expected AuthenticateEvent[E]) *mock.Call { + return m.On("OnEvent", expected) +} + +type mockAuthorizeListener[E any] struct { + mock.Mock +} + +func (m *mockAuthorizeListener[E]) OnEvent(e AuthorizeEvent[E]) { + m.Called(e) +} + +func (m *mockAuthorizeListener[E]) ExpectOnEvent(expected AuthorizeEvent[E]) *mock.Call { + return m.On("OnEvent", expected) +} diff --git a/testSuite_test.go b/testSuite_test.go index 004d265..2bf00e4 100644 --- a/testSuite_test.go +++ b/testSuite_test.go @@ -16,15 +16,16 @@ type TestSuite struct { } func (suite *TestSuite) testContext() context.Context { + type testContextKey struct{} return context.WithValue( context.Background(), - struct{}{}, + testContextKey{}, "test value", ) } func (suite *TestSuite) testToken() Token { - return stubToken("test") + return StubToken("test") } func (suite *TestSuite) contexter(ctx context.Context) Contexter { diff --git a/token.go b/token.go index f24e1e6..d0627e5 100644 --- a/token.go +++ b/token.go @@ -98,6 +98,11 @@ func AsTokenParser[S any, F TokenParserFunc[S]](f F) TokenParser[S] { // a given type of source. type TokenParsers[S any] []TokenParser[S] +// Len returns the number of parsers in this aggregate. +func (tps TokenParsers[S]) Len() int { + return len(tps) +} + // Append adds one or more parsers to this aggregate TokenParsers. The semantics // of this method are the same as the built-in append. func (tps TokenParsers[S]) Append(more ...TokenParser[S]) TokenParsers[S] { @@ -126,3 +131,21 @@ func (tps TokenParsers[S]) Parse(ctx context.Context, source S) (t Token, err er return } + +// StubToken is a dummy token useful to configure a stubbed out workflow. Useful +// in testing and in development. +type StubToken string + +// Principal just returns this token's string value. +func (st StubToken) Principal() string { return string(st) } + +// StubTokenParser is a parser that returns the same Token for all +// calls. Useful in testing and in development. +type StubTokenParser[S any] struct { + // Token is the constant token to return. This could be a StubToken, + // or any desired type. + Token Token +} + +// Parse always returns the configured Token and a nil error. +func (stp StubTokenParser[S]) Parse(context.Context, S) (Token, error) { return stp.Token, nil } diff --git a/token_test.go b/token_test.go index 9c5592f..8062bd1 100644 --- a/token_test.go +++ b/token_test.go @@ -24,7 +24,7 @@ type TokenParserSuite struct { func (suite *TokenParserSuite) SetupSuite() { suite.expectedCtx = suite.testContext() suite.expectedSource = 123 - suite.expectedToken = stubToken("expected token") + suite.expectedToken = StubToken("expected token") suite.expectedErr = errors.New("expected token parser error") } @@ -90,6 +90,8 @@ func (suite *TokenParserSuite) TestAsTokenParser() { // appendMissing appends a count of mocked TokenParser objects that return // (nil, ErrorMissingCredentials) and expect this suite's expected input. func (suite *TokenParserSuite) appendMissing(tps TokenParsers[int], count int) TokenParsers[int] { + initialLen := tps.Len() + for repeat := 0; repeat < count; repeat++ { m := new(mockTokenParser[int]) m.ExpectParse(suite.expectedCtx, suite.expectedSource). @@ -97,38 +99,47 @@ func (suite *TokenParserSuite) appendMissing(tps TokenParsers[int], count int) T tps = tps.Append(m) } + suite.Require().Equal(initialLen+count, tps.Len()) return tps } // appendSuccess appends a single mocked TokenParser that returns success using this // suite's expected inputs and outputs. func (suite *TokenParserSuite) appendSuccess(tps TokenParsers[int]) TokenParsers[int] { + initialLen := tps.Len() m := new(mockTokenParser[int]) m.ExpectParse(suite.expectedCtx, suite.expectedSource). Return(suite.expectedToken, nil).Once() - return tps.Append(m) + tps = tps.Append(m) + suite.Require().Equal(initialLen+1, tps.Len()) + return tps } // appendFail appends a single mocked TokenParser that returns a nil token and a failing // error, using this suite's expected inputs and outputs. func (suite *TokenParserSuite) appendFail(tps TokenParsers[int]) TokenParsers[int] { + initialLen := tps.Len() m := new(mockTokenParser[int]) m.ExpectParse(suite.expectedCtx, suite.expectedSource). Return(nil, suite.expectedErr).Once() - return tps.Append(m) + tps = tps.Append(m) + suite.Require().Equal(initialLen+1, tps.Len()) + return tps } // appendNoCall appends a count of mocked TokenParser objects that expect no calls to // be made. Useful to verify that a TokenParsers instance stops parsing upon // a successful parse or a non-missing error. func (suite *TokenParserSuite) appendNoCall(tps TokenParsers[int], count int) TokenParsers[int] { + initialLen := tps.Len() for repeat := 0; repeat < count; repeat++ { m := new(mockTokenParser[int]) tps = tps.Append(m) } + suite.Require().Equal(initialLen+count, tps.Len()) return tps } @@ -185,6 +196,17 @@ func (suite *TokenParserSuite) TestTokenParsers() { suite.Run("Fail", suite.testTokenParsersFail) } +func (suite *TokenParserSuite) TestStubTokenParser() { + stp := StubTokenParser[int]{ + Token: StubToken("test"), + } + + token, err := stp.Parse(context.Background(), 123) + suite.Require().NoError(err) + suite.Require().NotNil(token) + suite.Equal(token.Principal(), "test") +} + func TestTokenParser(t *testing.T) { suite.Run(t, new(TokenParserSuite)) } diff --git a/validator_test.go b/validator_test.go index 11b56c6..c7b4b33 100644 --- a/validator_test.go +++ b/validator_test.go @@ -25,8 +25,8 @@ type ValidatorsTestSuite struct { func (suite *ValidatorsTestSuite) SetupSuite() { suite.expectedCtx = suite.testContext() suite.expectedSource = 123 - suite.inputToken = stubToken("input token") - suite.outputToken = stubToken("output token") + suite.inputToken = StubToken("input token") + suite.outputToken = StubToken("output token") suite.expectedErr = errors.New("expected validator error") }