Skip to content

Commit f3920e4

Browse files
authored
fix(profile): return sentinel errors for wrong password and duplicate email (#931)
On the profile-update path, wrong current password and duplicate email both fell through to the generic 500 "Internal server error" because UpdateUserProfile returned plain fmt.Errorf strings that the handler did not recognise as client errors. - Add ErrCurrentPasswordIncorrect sentinel to internal/auth/errors.go. - Return the sentinel (not a plain string) from UpdateUserProfile when the current password does not match. - Return the existing ErrEmailInUse sentinel (not a plain string) from updateUserEmail when the address is taken. - Add mapProfileUpdateError in handler_auth.go: wrong password -> 401 with the sentinel message; duplicate email -> 409 with the neutral privacy-safe message "Unable to update email" (does not confirm another account's existence, preventing enumeration). - Add handler tests for both new error paths asserting the correct status code and message. - Add service-layer tests asserting errors.Is on both sentinels so the contract is positively enforced. Closes #929
1 parent 56a7797 commit f3920e4

5 files changed

Lines changed: 127 additions & 4 deletions

File tree

‎internal/api/handler_auth.go‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,12 +394,32 @@ func (h *Handler) updateProfile(ctx context.Context, req *events.LambdaFunctionU
394394

395395
// Update profile through auth service
396396
if err := h.auth.UpdateUserProfile(ctx, session.UserID, profileReq.Email, currentPassword, newPassword); err != nil {
397-
return nil, err
397+
return nil, mapProfileUpdateError(err)
398398
}
399399

400400
return map[string]string{"status": "profile updated"}, nil
401401
}
402402

403+
// mapProfileUpdateError converts profile-update service errors to the correct
404+
// HTTP status code and a user-facing message.
405+
//
406+
// - ErrCurrentPasswordIncorrect -> 401 with a precise message (the acting
407+
// user is verifying their own credential, so specificity is safe).
408+
// - ErrEmailInUse -> 409 with a neutral message that does NOT confirm whether
409+
// another account holds the address; prevents account enumeration via the
410+
// profile-update path (issue #929).
411+
// - All other errors pass through unchanged for handleRequestError to render
412+
// as 500.
413+
func mapProfileUpdateError(err error) error {
414+
switch {
415+
case errors.Is(err, auth.ErrCurrentPasswordIncorrect):
416+
return NewClientError(401, err.Error())
417+
case errors.Is(err, auth.ErrEmailInUse):
418+
return NewClientError(409, "Unable to update email")
419+
}
420+
return err
421+
}
422+
403423
// decodeProfilePasswords decodes the optional base64-encoded current and new
404424
// passwords from a ProfileUpdateRequest. Pulled out of updateProfile to keep
405425
// that function under the cyclomatic limit.

‎internal/api/handler_auth_test.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,68 @@ func TestHandler_updateProfile_AcceptsValidEmail(t *testing.T) {
947947
mockAuth.AssertCalled(t, "UpdateUserProfile", ctx, "12345678-1234-1234-1234-123456789abc", "new@example.com", "", "")
948948
}
949949

950+
// TestHandler_updateProfile_WrongCurrentPassword verifies that a wrong current
951+
// password returned by the auth service is surfaced as 401, not a generic 500
952+
// (issue #929). The acting user is checking their own credential so a precise
953+
// message is safe and helpful.
954+
func TestHandler_updateProfile_WrongCurrentPassword(t *testing.T) {
955+
ctx := context.Background()
956+
mockAuth := new(MockAuthService)
957+
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
958+
959+
session := &Session{UserID: "12345678-1234-1234-1234-123456789abc"}
960+
mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil)
961+
mockAuth.On("UpdateUserProfile", mock.MatchedBy(func(c context.Context) bool { return c != nil }), "12345678-1234-1234-1234-123456789abc", "", "wrongpass", "").
962+
Return(auth.ErrCurrentPasswordIncorrect)
963+
964+
handler := &Handler{auth: mockAuth}
965+
966+
encoded := base64.StdEncoding.EncodeToString([]byte("wrongpass"))
967+
req := &events.LambdaFunctionURLRequest{
968+
Headers: map[string]string{"Authorization": "Bearer test-token"},
969+
Body: `{"email": "", "current_password": "` + encoded + `"}`,
970+
}
971+
972+
_, err := handler.updateProfile(ctx, req)
973+
require.Error(t, err)
974+
ce, ok := IsClientError(err)
975+
require.True(t, ok, "wrong-password error must be a ClientError, not a 500")
976+
assert.Equal(t, 401, ce.code)
977+
assert.Equal(t, auth.ErrCurrentPasswordIncorrect.Error(), ce.message)
978+
}
979+
980+
// TestHandler_updateProfile_DuplicateEmail verifies that a duplicate-email
981+
// conflict is surfaced as 409 with a privacy-preserving message that does NOT
982+
// confirm whether another account holds the address (issue #929).
983+
func TestHandler_updateProfile_DuplicateEmail(t *testing.T) {
984+
ctx := context.Background()
985+
mockAuth := new(MockAuthService)
986+
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
987+
988+
session := &Session{UserID: "12345678-1234-1234-1234-123456789abc"}
989+
mockAuth.On("ValidateSession", ctx, "test-token").Return(session, nil)
990+
mockAuth.On("UpdateUserProfile", mock.MatchedBy(func(c context.Context) bool { return c != nil }), "12345678-1234-1234-1234-123456789abc", "taken@example.com", "mypass", "").
991+
Return(auth.ErrEmailInUse)
992+
993+
handler := &Handler{auth: mockAuth}
994+
995+
encoded := base64.StdEncoding.EncodeToString([]byte("mypass"))
996+
req := &events.LambdaFunctionURLRequest{
997+
Headers: map[string]string{"Authorization": "Bearer test-token"},
998+
Body: `{"email": "taken@example.com", "current_password": "` + encoded + `"}`,
999+
}
1000+
1001+
_, err := handler.updateProfile(ctx, req)
1002+
require.Error(t, err)
1003+
ce, ok := IsClientError(err)
1004+
require.True(t, ok, "duplicate-email error must be a ClientError, not a 500")
1005+
assert.Equal(t, 409, ce.code)
1006+
// Must NOT expose the raw ErrEmailInUse message which would confirm another
1007+
// account exists. The privacy-preserving message is "Unable to update email".
1008+
assert.Equal(t, "Unable to update email", ce.message)
1009+
assert.NotContains(t, ce.message, "already in use", "response must not confirm another account's existence")
1010+
}
1011+
9501012
// TestHandler_resetPassword_DecodesBase64 verifies issue #356: the
9511013
// resetPassword handler must base64-decode new_password before forwarding to
9521014
// the service, matching the pattern used by login / change-password /

‎internal/auth/errors.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ var (
3434
// permission. Mapped to 403 (issue #907).
3535
ErrSelfEscalation = errors.New("cannot escalate your own group membership")
3636

37+
// ErrCurrentPasswordIncorrect is returned by UpdateUserProfile when the
38+
// caller-supplied current password does not match the stored hash. Mapped
39+
// to 401 at the API layer (the acting user is verifying their own
40+
// credential, so a precise message is safe -- issue #929).
41+
ErrCurrentPasswordIncorrect = errors.New("Current password is incorrect")
42+
3743
// MFA login-gate sentinels — used by the login API handler to map
3844
// to machine-readable response codes (mfa_required /
3945
// invalid_mfa_code) so the frontend can branch on the error class

‎internal/auth/service_user.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID string, email st
452452
}
453453

454454
if !s.verifyPassword(currentPassword, user.PasswordHash) {
455-
return fmt.Errorf("current password is incorrect")
455+
return ErrCurrentPasswordIncorrect
456456
}
457457

458458
if err := s.updateUserEmail(ctx, user, email); err != nil {
@@ -492,7 +492,7 @@ func (s *Service) updateUserEmail(ctx context.Context, user *User, email string)
492492
return err
493493
}
494494
if existing != nil {
495-
return fmt.Errorf("email already in use")
495+
return ErrEmailInUse
496496
}
497497
user.Email = email
498498
}

‎internal/auth/service_user_test.go‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"testing"
78
"time"
@@ -794,7 +795,41 @@ func TestService_UpdateUserProfile(t *testing.T) {
794795

795796
err := service.UpdateUserProfile(ctx, "user-123", "new@example.com", "WrongPassword", "SecureTest@456")
796797
assert.Error(t, err)
797-
assert.Contains(t, err.Error(), "current password is incorrect")
798+
// The handler must receive the sentinel so it can produce a 401 instead
799+
// of a 500. Assert errors.Is in addition to the string check (issue #929).
800+
assert.True(t, errors.Is(err, ErrCurrentPasswordIncorrect),
801+
"UpdateUserProfile wrong-password must return ErrCurrentPasswordIncorrect sentinel")
802+
803+
mockStore.AssertExpectations(t)
804+
})
805+
806+
t.Run("duplicate email returns ErrEmailInUse sentinel", func(t *testing.T) {
807+
// Verifies issue #929: updateUserEmail must return the ErrEmailInUse
808+
// sentinel (not a plain fmt.Errorf string) so the API handler can map
809+
// it to a 409 with a privacy-preserving message.
810+
mockStore := new(MockStore)
811+
mockEmail := new(MockEmailSender)
812+
service := createTestService(mockStore, mockEmail)
813+
814+
hash, _ := bcrypt.GenerateFromPassword([]byte("OldPassword123"), bcrypt.DefaultCost)
815+
testUser := &User{
816+
ID: "user-123",
817+
Email: "old@example.com",
818+
PasswordHash: string(hash),
819+
Active: true,
820+
}
821+
otherUser := &User{
822+
ID: "other-456",
823+
Email: "taken@example.com",
824+
}
825+
826+
mockStore.On("GetUserByID", ctx, "user-123").Return(testUser, nil).Once()
827+
mockStore.On("GetUserByEmail", ctx, "taken@example.com").Return(otherUser, nil).Once()
828+
829+
err := service.UpdateUserProfile(ctx, "user-123", "taken@example.com", "OldPassword123", "")
830+
assert.Error(t, err)
831+
assert.True(t, errors.Is(err, ErrEmailInUse),
832+
"UpdateUserProfile duplicate-email must return ErrEmailInUse sentinel")
798833

799834
mockStore.AssertExpectations(t)
800835
})

0 commit comments

Comments
 (0)