diff options
| author | Brynn Crowley <littlehill723@gmail.com> | 2025-03-08 15:04:15 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-08 15:04:15 +0000 |
| commit | 1c907929c614779adb963c97776810cdba8ce5f6 (patch) | |
| tree | 746e3f015490252cc8b96af0fe4b1ba894a987bb | |
| parent | 9241731a4dd5592b4a02b5352c903b4d06b6f4ab (diff) | |
refactor(handlers): add more detailed errors for password-change failures (#8899)
Adds some more helpful log information to the change password feature.
Signed-off-by: Brynn Crowley <littlehill723@gmail.com>
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
Co-authored-by: James Elliott <james-d-elliott@users.noreply.github.com>
| -rw-r--r-- | internal/authentication/ldap_user_provider.go | 14 | ||||
| -rw-r--r-- | internal/authentication/ldap_user_provider_test.go | 253 | ||||
| -rw-r--r-- | internal/authentication/ldap_util.go | 2 | ||||
| -rw-r--r-- | internal/handlers/const.go | 1 | ||||
| -rw-r--r-- | internal/handlers/handler_change_password.go | 76 | ||||
| -rw-r--r-- | internal/handlers/handler_change_password_test.go | 73 | ||||
| -rw-r--r-- | internal/mocks/authelia_ctx.go | 49 |
7 files changed, 414 insertions, 54 deletions
diff --git a/internal/authentication/ldap_user_provider.go b/internal/authentication/ldap_user_provider.go index 502dbe502..9480717a6 100644 --- a/internal/authentication/ldap_user_provider.go +++ b/internal/authentication/ldap_user_provider.go @@ -338,7 +338,7 @@ func (p *LDAPUserProvider) ChangePassword(username, oldPassword string, newPassw userPasswordOk, err := p.CheckUserPassword(username, oldPassword) if err != nil { - errorCode := ldapGetErrorCode(err) + errorCode := getLDAPResultCode(err) if errorCode == ldap.LDAPResultInvalidCredentials { return ErrIncorrectPassword } else { @@ -385,24 +385,22 @@ func (p *LDAPUserProvider) ChangePassword(username, oldPassword string, newPassw //TODO: Better inform users regarding password reuse/password history. if err != nil { - if errorCode := ldapGetErrorCode(err); errorCode != -1 { + if errorCode := getLDAPResultCode(err); errorCode != -1 { switch errorCode { case ldap.LDAPResultInvalidCredentials, ldap.LDAPResultInappropriateAuthentication: - return ErrIncorrectPassword + return fmt.Errorf("%w: %v", ErrIncorrectPassword, err) case ldap.LDAPResultConstraintViolation, ldap.LDAPResultObjectClassViolation, ldap.ErrorEmptyPassword, ldap.LDAPResultUnwillingToPerform: - return ErrPasswordWeak - case ldap.LDAPResultInsufficientAccessRights: - return ErrOperationFailed + return fmt.Errorf("%w: %v", ErrPasswordWeak, err) default: - return ErrOperationFailed + return fmt.Errorf("%w: %v", ErrOperationFailed, err) } } - return ErrOperationFailed + return fmt.Errorf("%w: %v", ErrOperationFailed, err) } return nil diff --git a/internal/authentication/ldap_user_provider_test.go b/internal/authentication/ldap_user_provider_test.go index 7b6b4ee73..5f2e13091 100644 --- a/internal/authentication/ldap_user_provider_test.go +++ b/internal/authentication/ldap_user_provider_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/go-ldap/ldap/v3" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -6520,3 +6522,254 @@ func TestShouldReturnLDAPSAlreadySecuredWhenStartTLSAttempted(t *testing.T) { _, err := provider.GetDetails("john") assert.EqualError(t, err, "error occurred performing starttls: LDAP Result Code 200 \"Network Error\": ldap: already encrypted") } + +func TestLDAPUserProviderChangePasswordErrors(t *testing.T) { + testCases := []struct { + name string + setupMocks func(ctrl *gomock.Controller) (*MockLDAPClientDialer, *MockLDAPClient) + username string + oldPassword string + newPassword string + expectedError error + expectedLogMsg string + expectedLogType logrus.Level + }{ + { + name: "ShouldFailWhenClientError", + setupMocks: func(ctrl *gomock.Controller) (*MockLDAPClientDialer, *MockLDAPClient) { + mockDialer := NewMockLDAPClientDialer(ctrl) + mockClient := NewMockLDAPClient(ctrl) + + mockDialer.EXPECT().DialURL("ldap://127.0.0.1:389", gomock.Any()). + Return(nil, errors.New("connection error")) + + return mockDialer, mockClient + }, + username: "john", + oldPassword: "oldpass", + newPassword: "newpass", + expectedError: fmt.Errorf("unable to update password for user 'john'. Cause: error occurred dialing address: connection error"), + expectedLogMsg: "", + expectedLogType: 0, + }, + { + name: "ShouldFailWhenGetUserProfileError", + setupMocks: func(ctrl *gomock.Controller) (*MockLDAPClientDialer, *MockLDAPClient) { + mockDialer := NewMockLDAPClientDialer(ctrl) + mockClient := NewMockLDAPClient(ctrl) + + mockDialer.EXPECT().DialURL("ldap://127.0.0.1:389", gomock.Any()). + Return(mockClient, nil) + + mockClient.EXPECT(). + Bind(gomock.Eq("cn=admin,dc=example,dc=com"), gomock.Eq("password")). + Return(nil) + + mockClient.EXPECT(). + Search(gomock.Any()). + Return(nil, errors.New("search error")) + + mockClient.EXPECT().Close() + + return mockDialer, mockClient + }, + username: "john", + oldPassword: "oldpass", + newPassword: "newpass", + expectedError: fmt.Errorf("unable to update password for user 'john'. Cause: cannot find user DN of user 'john'. Cause: search error"), + expectedLogMsg: "", + expectedLogType: 0, + }, + { + name: "ShouldFailWithInvalidCredentials", + setupMocks: func(ctrl *gomock.Controller) (*MockLDAPClientDialer, *MockLDAPClient) { + mockDialer := NewMockLDAPClientDialer(ctrl) + mockClient := NewMockLDAPClient(ctrl) + + mockDialer.EXPECT().DialURL("ldap://127.0.0.1:389", gomock.Any()). + Return(mockClient, nil) + + mockDialer.EXPECT().DialURL("ldap://127.0.0.1:389", gomock.Any()). + Return(mockClient, nil) + + mockDialer.EXPECT().DialURL("ldap://127.0.0.1:389", gomock.Any()). + Return(mockClient, nil) + + mockClient.EXPECT().Bind("cn=admin,dc=example,dc=com", "password"). + Return(nil) + + mockClient.EXPECT().Bind("cn=admin,dc=example,dc=com", "password"). + Return(nil) + + mockClient.EXPECT().Search(gomock.Any()). + Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "uid=john,ou=users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "uid", + Values: []string{"john"}, + }, + }, + }, + }, + }, nil) + + mockClient.EXPECT().Search(gomock.Any()). + Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "uid=john,ou=users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + { + Name: "uid", + Values: []string{"john"}, + }, + }, + }, + }, + }, nil) + + mockClient.EXPECT().Bind("uid=john,ou=users,dc=example,dc=com", "oldpass"). + Return(ldap.NewError(ldap.LDAPResultInvalidCredentials, errors.New("invalid credentials"))) + + mockClient.EXPECT().Close().Times(3) + + return mockDialer, mockClient + }, + username: "john", + oldPassword: "oldpass", + newPassword: "newpass", + expectedError: ErrIncorrectPassword, + expectedLogMsg: "", + expectedLogType: 0, + }, + { + name: "ShouldFailWhenSamePassword", + setupMocks: func(ctrl *gomock.Controller) (*MockLDAPClientDialer, *MockLDAPClient) { + mockDialer := NewMockLDAPClientDialer(ctrl) + mockClient := NewMockLDAPClient(ctrl) + + mockDialer.EXPECT().DialURL("ldap://127.0.0.1:389", gomock.Any()). + Return(mockClient, nil).Times(3) + + mockClient.EXPECT().Bind("cn=admin,dc=example,dc=com", "password"). + Return(nil).Times(2) + + mockClient.EXPECT().Search(gomock.Any()). + Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "uid=john,ou=users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + {Name: "uid", Values: []string{"john"}}, + }, + }, + }, + }, nil).Times(2) + + mockClient.EXPECT().Bind("uid=john,ou=users,dc=example,dc=com", "samepass"). + Return(nil) + + mockClient.EXPECT().Close().Times(3) + + return mockDialer, mockClient + }, + username: "john", + oldPassword: "samepass", + newPassword: "samepass", + expectedError: ErrPasswordWeak, + expectedLogMsg: "", + expectedLogType: 0, + }, + { + name: "ShouldFailOnModifyError", + setupMocks: func(ctrl *gomock.Controller) (*MockLDAPClientDialer, *MockLDAPClient) { + mockDialer := NewMockLDAPClientDialer(ctrl) + mockClient := NewMockLDAPClient(ctrl) + + mockDialer.EXPECT().DialURL("ldap://127.0.0.1:389", gomock.Any()). + Return(mockClient, nil).Times(3) + + mockClient.EXPECT().Bind("cn=admin,dc=example,dc=com", "password"). + Return(nil).Times(2) + + mockClient.EXPECT().Search(gomock.Any()). + Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "uid=john,ou=users,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + {Name: "uid", Values: []string{"john"}}, + }, + }, + }, + }, nil).Times(2) + + mockClient.EXPECT().Bind("uid=john,ou=users,dc=example,dc=com", "oldpass"). + Return(nil) + + mockClient.EXPECT().Modify(gomock.Any()). + Return(ldap.NewError(ldap.LDAPResultConstraintViolation, errors.New("password too weak"))) + + mockClient.EXPECT().Close().Times(3) + + return mockDialer, mockClient + }, + username: "john", + oldPassword: "oldpass", + newPassword: "newpass", + expectedError: fmt.Errorf("your supplied password does not meet the password policy requirements: LDAP Result Code 19 \"Constraint Violation\": password too weak"), + expectedLogMsg: "", + expectedLogType: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // can't use mocks package due to circular import (mocks -> authentication, authentication(test) -> mocks). + logger := logrus.New() + hook := test.NewLocal(logger) + logger.SetLevel(logrus.TraceLevel) + + mockDialer, _ := tc.setupMocks(ctrl) + + config := &schema.AuthenticationBackendLDAP{ + Address: testLDAPAddress, + User: "cn=admin,dc=example,dc=com", + Password: "password", + Attributes: schema.AuthenticationBackendLDAPAttributes{ + Username: "uid", + Mail: "mail", + DisplayName: "displayName", + MemberOf: "memberOf", + }, + UsersFilter: "uid={input}", + AdditionalUsersDN: "ou=users", + BaseDN: "dc=example,dc=com", + } + + provider := NewLDAPUserProviderWithFactory(config, false, NewStandardLDAPClientFactory(config, nil, mockDialer)) + + err := provider.ChangePassword(tc.username, tc.oldPassword, tc.newPassword) + + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + + if tc.expectedLogMsg != "" { + entry := hook.LastEntry() + require.NotNil(t, entry) + assert.Equal(t, tc.expectedLogType, logger.Level) + assert.Contains(t, entry.Message, tc.expectedLogMsg) + } + }) + } +} diff --git a/internal/authentication/ldap_util.go b/internal/authentication/ldap_util.go index 5793a661b..a50c69e2a 100644 --- a/internal/authentication/ldap_util.go +++ b/internal/authentication/ldap_util.go @@ -111,7 +111,7 @@ func ldapGetReferral(err error) (referral string, ok bool) { } } -func ldapGetErrorCode(err error) int { +func getLDAPResultCode(err error) int { var e *ldap.Error if errors.As(err, &e) { diff --git a/internal/handlers/const.go b/internal/handlers/const.go index 884095d5f..ac7a48523 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -69,7 +69,6 @@ const ( messageUnableToRegisterSecurityKey = "Unable to register your security key." messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name." messageUnableToResetPassword = "Unable to reset your password." - messageCannotReusePassword = "You cannot reuse your old password." messageUnableToChangePassword = "Unable to change your password." messageIncorrectPassword = "Incorrect Password" messageMFAValidationFailed = "Authentication failed, please retry later." diff --git a/internal/handlers/handler_change_password.go b/internal/handlers/handler_change_password.go index cbdd25ddc..cad4de1e5 100644 --- a/internal/handlers/handler_change_password.go +++ b/internal/handlers/handler_change_password.go @@ -2,7 +2,6 @@ package handlers import ( "errors" - "fmt" "net/http" "github.com/authelia/authelia/v4/internal/authentication" @@ -14,11 +13,25 @@ import ( func ChangePasswordPOST(ctx *middlewares.AutheliaCtx) { var ( userSession session.UserSession + provider *session.Session err error ) - if userSession, err = ctx.GetSession(); err != nil { - ctx.Error(fmt.Errorf("error occurred retrieving session for user: %w", err), messageUnableToChangePassword) + if provider, err = ctx.GetSessionProvider(); err != nil { + ctx.Logger.WithError(err). + Error("Unable to change password for user: error occurred retrieving session provider") + ctx.SetJSONError(messageUnableToChangePassword) + ctx.SetStatusCode(http.StatusInternalServerError) + + return + } + + if userSession, err = provider.GetSession(ctx.RequestCtx); err != nil { + ctx.Logger.WithError(err). + Error("Unable to change password for user: error occurred retrieving session for user") + ctx.SetJSONError(messageUnableToChangePassword) + ctx.SetStatusCode(http.StatusInternalServerError) + return } @@ -27,29 +40,49 @@ func ChangePasswordPOST(ctx *middlewares.AutheliaCtx) { var requestBody changePasswordRequestBody if err = ctx.ParseBody(&requestBody); err != nil { - ctx.Error(err, messageUnableToChangePassword) + ctx.Logger.WithError(err). + WithFields(map[string]any{"username": username}). + Error("Unable to change password for user: unable to parse request body") + ctx.SetJSONError(messageUnableToChangePassword) + ctx.SetStatusCode(http.StatusBadRequest) + return } if err = ctx.Providers.PasswordPolicy.Check(requestBody.NewPassword); err != nil { - ctx.Error(err, messagePasswordWeak) + ctx.Logger.WithError(err). + WithFields(map[string]any{"username": username}). + Debug("Unable to change password for user as their new password was weak or empty") + ctx.SetJSONError(messagePasswordWeak) + ctx.SetStatusCode(http.StatusBadRequest) + return } if err = ctx.Providers.UserProvider.ChangePassword(username, requestBody.OldPassword, requestBody.NewPassword); err != nil { - ctx.Logger.WithError(err).Debugf("Unable to change password for user '%s'", username) - switch { case errors.Is(err, authentication.ErrIncorrectPassword): + ctx.Logger.WithError(err). + WithFields(map[string]any{"username": username}). + Debug("Unable to change password for user as their old password was incorrect") ctx.SetJSONError(messageIncorrectPassword) ctx.SetStatusCode(http.StatusUnauthorized) case errors.Is(err, authentication.ErrPasswordWeak): + ctx.Logger.WithError(err). + WithFields(map[string]any{"username": username}). + Debug("Unable to change password for user as their new password was weak or empty") ctx.SetJSONError(messagePasswordWeak) ctx.SetStatusCode(http.StatusBadRequest) case errors.Is(err, authentication.ErrAuthenticationFailed): + ctx.Logger.WithError(err). + WithFields(map[string]any{"username": username}). + Error("Unable to change password for user as authentication failed for the user") ctx.SetJSONError(messageOperationFailed) ctx.SetStatusCode(http.StatusUnauthorized) default: + ctx.Logger.WithError(err). + WithFields(map[string]any{"username": username}). + Error("Unable to change password for user for an unknown reason") ctx.SetJSONError(messageOperationFailed) ctx.SetStatusCode(http.StatusInternalServerError) } @@ -57,10 +90,16 @@ func ChangePasswordPOST(ctx *middlewares.AutheliaCtx) { return } - ctx.Logger.Debugf("User %s has changed their password", username) + ctx.Logger. + WithFields(map[string]any{"username": username}). + Debug("User has changed their password") + + if err = provider.SaveSession(ctx.RequestCtx, userSession); err != nil { + ctx.Logger.WithError(err). + WithFields(map[string]any{"username": username}). + Error("Unable to update password change state") + ctx.SetJSONError(messageOperationFailed) - if err = ctx.SaveSession(userSession); err != nil { - ctx.Error(fmt.Errorf("unable to update password reset state: %w", err), messageOperationFailed) return } @@ -73,7 +112,8 @@ func ChangePasswordPOST(ctx *middlewares.AutheliaCtx) { } if len(userInfo.Emails) == 0 { - ctx.Logger.Error(fmt.Errorf("user %s has no email address configured", username)) + ctx.Logger.WithFields(map[string]any{"username": username}). + Debug("user has no email address configured") ctx.ReplyOK() return @@ -93,11 +133,19 @@ func ChangePasswordPOST(ctx *middlewares.AutheliaCtx) { addresses := userInfo.Addresses() - ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.", - username, addresses[0].String()) + ctx.Logger.WithFields(map[string]any{ + "username": username, + "email": addresses[0].String(), + }). + Debug("Sending an email to inform user that their password has changed.") if err = ctx.Providers.Notifier.Send(ctx, addresses[0], "Password changed successfully", ctx.Providers.Templates.GetEventEmailTemplate(), data); err != nil { - ctx.Logger.Error(err) + ctx.Logger.WithError(err). + WithFields(map[string]any{ + "username": username, + "email": addresses[0].String(), + }). + Debug("Unable to notify user of password change") ctx.ReplyOK() return diff --git a/internal/handlers/handler_change_password_test.go b/internal/handlers/handler_change_password_test.go index 3783fef8e..920b2975e 100644 --- a/internal/handlers/handler_change_password_test.go +++ b/internal/handlers/handler_change_password_test.go @@ -3,15 +3,15 @@ package handlers import ( "encoding/json" "fmt" + "regexp" "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" "github.com/valyala/fasthttp" "go.uber.org/mock/gomock" "github.com/authelia/authelia/v4/internal/authentication" - "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/mocks" @@ -22,30 +22,13 @@ const ( testPasswordNew = "new_password456" ) -type ChangePasswordSuite struct { - suite.Suite - mock *mocks.MockAutheliaCtx -} - -func (s *ChangePasswordSuite) SetupTest() { - s.mock = mocks.NewMockAutheliaCtx(s.T()) - userSession, err := s.mock.Ctx.GetSession() - s.Assert().NoError(err) - - userSession.Username = testUsername - userSession.DisplayName = testUsername - userSession.Emails[0] = testEmail - userSession.AuthenticationMethodRefs.UsernameAndPassword = true - s.Assert().NoError(s.mock.Ctx.SaveSession(userSession)) -} - -func (s *ChangePasswordSuite) TearDownTest() { - s.mock.Close() -} - func TestChangePasswordPOST_ShouldSucceedWithValidCredentials(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Ctx.Logger.Logger.SetLevel(logrus.DebugLevel) + userSession, err := mock.Ctx.GetSession() assert.NoError(t, err) @@ -83,12 +66,18 @@ func TestChangePasswordPOST_ShouldSucceedWithValidCredentials(t *testing.T) { ChangePasswordPOST(mock.Ctx) + mock.AssertLogEntryAdvanced(t, 1, logrus.DebugLevel, "User has changed their password", map[string]any{"username": testUsername}) + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) } func TestChangePasswordPOST_ShouldFailWhenPasswordPolicyNotMet(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Ctx.Logger.Logger.SetLevel(logrus.DebugLevel) + userSession, err := mock.Ctx.GetSession() assert.NoError(t, err) @@ -124,6 +113,8 @@ func TestChangePasswordPOST_ShouldFailWhenPasswordPolicyNotMet(t *testing.T) { ChangePasswordPOST(mock.Ctx) + mock.AssertLogEntryAdvanced(t, 0, logrus.DebugLevel, "Unable to change password for user as their new password was weak or empty", map[string]any{"username": testUsername, "error": "the supplied password does not met the security policy"}) + errResponse := mock.GetResponseError(t) assert.Equal(t, "KO", errResponse.Status) @@ -133,6 +124,10 @@ func TestChangePasswordPOST_ShouldFailWhenPasswordPolicyNotMet(t *testing.T) { func TestChangePasswordPOST_ShouldFailWhenRequestBodyIsInvalid(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Ctx.Logger.Logger.SetLevel(logrus.DebugLevel) + userSession, err := mock.Ctx.GetSession() assert.NoError(t, err) @@ -144,6 +139,8 @@ func TestChangePasswordPOST_ShouldFailWhenRequestBodyIsInvalid(t *testing.T) { ChangePasswordPOST(mock.Ctx) + mock.AssertLogEntryAdvanced(t, 0, logrus.ErrorLevel, "Unable to change password for user: unable to parse request body", map[string]any{"username": testUsername, "error": regexp.MustCompile(`^(unable to parse body: .+|unable to validate body: .+|Body is not valid)$`)}) + errResponse := mock.GetResponseError(t) assert.Equal(t, "KO", errResponse.Status) assert.Equal(t, messageUnableToChangePassword, errResponse.Message) @@ -152,6 +149,10 @@ func TestChangePasswordPOST_ShouldFailWhenRequestBodyIsInvalid(t *testing.T) { func TestChangePasswordPOST_ShouldFailWhenOldPasswordIsIncorrect(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Ctx.Logger.Logger.SetLevel(logrus.DebugLevel) + userSession, err := mock.Ctx.GetSession() assert.NoError(t, err) @@ -179,6 +180,11 @@ func TestChangePasswordPOST_ShouldFailWhenOldPasswordIsIncorrect(t *testing.T) { ChangePasswordPOST(mock.Ctx) + mock.AssertLogEntryAdvanced(t, 0, logrus.DebugLevel, "Unable to change password for user as their old password was incorrect", map[string]any{"username": testUsername, "error": "incorrect password"}) + + errorField := mock.Hook.LastEntry().Data["error"] + assert.ErrorIs(t, authentication.ErrIncorrectPassword, errorField.(error)) + errResponse := mock.GetResponseError(t) assert.Equal(t, "KO", errResponse.Status) assert.Equal(t, messageIncorrectPassword, errResponse.Message) @@ -187,6 +193,10 @@ func TestChangePasswordPOST_ShouldFailWhenOldPasswordIsIncorrect(t *testing.T) { func TestChangePasswordPOST_ShouldFailWhenPasswordReuseIsNotAllowed(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Ctx.Logger.Logger.SetLevel(logrus.DebugLevel) + userSession, err := mock.Ctx.GetSession() assert.NoError(t, err) @@ -214,6 +224,11 @@ func TestChangePasswordPOST_ShouldFailWhenPasswordReuseIsNotAllowed(t *testing.T ChangePasswordPOST(mock.Ctx) + mock.AssertLogEntryAdvanced(t, 0, logrus.DebugLevel, "Unable to change password for user as their new password was weak or empty", map[string]any{"username": testUsername, "error": "your supplied password does not meet the password policy requirements"}) + + errorField := mock.Hook.LastEntry().Data["error"] + assert.ErrorIs(t, authentication.ErrPasswordWeak, errorField.(error)) + errResponse := mock.GetResponseError(t) assert.Equal(t, "KO", errResponse.Status) assert.Equal(t, messagePasswordWeak, errResponse.Message) @@ -221,6 +236,9 @@ func TestChangePasswordPOST_ShouldFailWhenPasswordReuseIsNotAllowed(t *testing.T func TestChangePasswordPOST_ShouldSucceedButLogErrorWhenUserHasNoEmail(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Ctx.Logger.Logger.SetLevel(logrus.DebugLevel) userSession, err := mock.Ctx.GetSession() assert.NoError(t, err) @@ -255,11 +273,16 @@ func TestChangePasswordPOST_ShouldSucceedButLogErrorWhenUserHasNoEmail(t *testin ChangePasswordPOST(mock.Ctx) + mock.AssertLogEntryAdvanced(t, 1, logrus.DebugLevel, "User has changed their password", map[string]any{"username": testUsername}) + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) } func TestChangePasswordPOST_ShouldSucceedButLogErrorWhenNotificationFails(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Ctx.Logger.Logger.SetLevel(logrus.DebugLevel) userSession, err := mock.Ctx.GetSession() assert.NoError(t, err) @@ -294,9 +317,11 @@ func TestChangePasswordPOST_ShouldSucceedButLogErrorWhenNotificationFails(t *tes mock.NotifierMock.EXPECT(). Send(mock.Ctx, gomock.Any(), "Password changed successfully", gomock.Any(), gomock.Any()). - Return(fmt.Errorf("failed to send notification")) + Return(fmt.Errorf("notifier: smtp: failed to send message: connection refused")) ChangePasswordPOST(mock.Ctx) + mock.AssertLogEntryAdvanced(t, 0, logrus.DebugLevel, "Unable to notify user of password change", map[string]any{"username": testUsername, "email": nil, "error": regexp.MustCompile(`^notifier: smtp: failed to .*: .+$`)}) + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) } diff --git a/internal/mocks/authelia_ctx.go b/internal/mocks/authelia_ctx.go index b69f03ab2..f1d7202c2 100644 --- a/internal/mocks/authelia_ctx.go +++ b/internal/mocks/authelia_ctx.go @@ -268,16 +268,14 @@ func (m *MockAutheliaCtx) SetRequestBody(t *testing.T, body interface{}) { m.Ctx.Request.SetBody(bodyBytes) } -func (m *MockAutheliaCtx) LogEntryN(i int) *logrus.Entry { +func (m *MockAutheliaCtx) LogEntryN(n int) *logrus.Entry { entries := m.Hook.AllEntries() - n := len(entries) - (1 + i) - - if n < 0 { + if i := len(entries) - (1 + n); i < 0 { return nil + } else { + return entries[i] } - - return entries[n] } // AssertKO assert an error response from the service. @@ -376,6 +374,22 @@ func (m *MockAutheliaCtx) AssertLastLogMessage(t *testing.T, message, err string } } +func (m *MockAutheliaCtx) AssertLogEntryAdvanced(t *testing.T, n int, level logrus.Level, message any, fields map[string]any) { + entry := m.LogEntryN(n) + + require.NotNil(t, entry) + + assert.Equal(t, level, entry.Level) + + AssertIsStringEqualOrRegexp(t, message, entry.Message) + + for field, expected := range fields { + require.Contains(t, entry.Data, field) + + AssertIsStringEqualOrRegexp(t, expected, entry.Data[field]) + } +} + // GetResponseData retrieves a response from the service. func (m *MockAutheliaCtx) GetResponseData(t *testing.T, data interface{}) { okResponse := middlewares.OKResponse{} @@ -391,3 +405,26 @@ func (m *MockAutheliaCtx) GetResponseError(t *testing.T) (errResponse middleware return errResponse } + +func AssertIsStringEqualOrRegexp(t *testing.T, expected any, actual any) { + switch v := expected.(type) { + case string: + switch a := actual.(type) { + case error: + assert.EqualError(t, a, v) + default: + assert.Equal(t, v, a) + } + case *regexp.Regexp: + switch a := actual.(type) { + case error: + assert.Regexp(t, v, a.Error()) + default: + assert.Regexp(t, v, a) + } + case nil: + break + default: + t.Fatal("Expected value must be a string or Regexp") + } +} |
