fix: remove the unused user use cases, fix the specs that no longer match the code, include api/middleware and api/pkg/claims in mutation testing, log failures that password reset requests currently ignore, and reject user_id claims that don't fit a user id

This commit is contained in:
fsylvain committed 2026-09-25 05:58:37 +02:00
1 parent cbb403011a
commit 3282b0580c
22 files changed
+260 -314

No files matched your search

+86
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/pkg/claims"
"math"
"net/http"
"net/http/httptest"
"testing"
@@ -65,3 +66,88 @@ func TestClaimsTokenVersion(t *testing.T) {
}
})
}
func TestClaimsUserId(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("returns the numeric user_id claim", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7)})
got, ok := claims.UserId(req)
if !ok {
t.Fatalf("expected ok true")
}
if got != 7 {
t.Errorf("expected 7, got %d", got)
}
})
t.Run("returns ok false when the user_id key is missing", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"role": "user"})
_, ok := claims.UserId(req)
if ok {
t.Errorf("expected ok false")
}
})
t.Run("returns ok false when user_id is not numeric", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": "one"})
_, ok := claims.UserId(req)
if ok {
t.Errorf("expected ok false")
}
})
t.Run("returns ok false when user_id is negative", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": -1.0})
_, ok := claims.UserId(req)
if ok {
t.Errorf("expected ok false")
}
})
t.Run("returns ok false when user_id has a fractional part", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": 7.5})
_, ok := claims.UserId(req)
if ok {
t.Errorf("expected ok false")
}
})
t.Run("returns ok true when user_id is exactly zero", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": float64(0)})
got, ok := claims.UserId(req)
if !ok {
t.Fatalf("expected ok true")
}
if got != 0 {
t.Errorf("expected 0, got %d", got)
}
})
t.Run("returns ok true when user_id is exactly the uint32 max", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": float64(math.MaxUint32)})
got, ok := claims.UserId(req)
if !ok {
t.Fatalf("expected ok true")
}
if got != math.MaxUint32 {
t.Errorf("expected %d, got %d", uint32(math.MaxUint32), got)
}
})
t.Run("returns ok false when user_id is one past the uint32 max", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": float64(4294967296)})
_, ok := claims.UserId(req)
if ok {
t.Errorf("expected ok false")
}
})
}
-59
View File
@@ -14,7 +14,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
@@ -77,64 +76,6 @@ func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
})
}
func TestUpdateUserUseCaseHashesUpdatedValues(t *testing.T) {
cases := []struct {
method string
plaintext string
run func(uc *useCases.UpdateUserUseCase, plaintext string) (user.User, error)
stored func(u user.User) string
}{
{
method: "UpdatePassword",
plaintext: "newPassw0rd",
run: func(uc *useCases.UpdateUserUseCase, plaintext string) (user.User, error) {
return uc.UpdatePassword(1, plaintext)
},
stored: func(u user.User) string { return u.Password },
},
{
method: "UpdatePasswordResetCode",
plaintext: "reset-code-123",
run: func(uc *useCases.UpdateUserUseCase, plaintext string) (user.User, error) {
return uc.UpdatePasswordResetCode(1, plaintext, time.Now().Add(time.Hour))
},
stored: func(u user.User) string { return u.PasswordResetCode },
},
}
for _, tc := range cases {
t.Run(tc.method+" stores the hashed value", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := tc.run(uc, tc.plaintext)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
expected := "hashed:" + tc.plaintext
if got := tc.stored(repo.users[1]); got != expected {
t.Errorf("expected stored value %q, got %q", expected, got)
}
})
t.Run(tc.method+" a hashing failure persists nothing", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure})
_, err := tc.run(uc, tc.plaintext)
if !errors.Is(err, errStubHashFailure) {
t.Errorf("expected the hashing error, got %v", err)
}
if got := tc.stored(repo.users[1]); got != "" {
t.Errorf("expected the repository update to never be called, got %q", got)
}
})
}
}
func TestAuthenticateUserUseCase(t *testing.T) {
newRepo := func() *mockUserRepo {
repo := newMockUserRepo()
+8
View File
@@ -172,6 +172,8 @@ func TestRequestPasswordResetHTTP(t *testing.T) {
mailRepo.fail.failNext("Send")
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
logBuf := captureRequestPasswordResetLog(t)
recorder := sendPasswordResetRequest(router, `{"email": "alice@example.com"}`)
if recorder.Code != http.StatusOK {
@@ -183,6 +185,12 @@ func TestRequestPasswordResetHTTP(t *testing.T) {
if recorder.Header().Get("Set-Cookie") != "" {
t.Errorf("expected no jwt cookie to be set, got %s", recorder.Header().Get("Set-Cookie"))
}
if logBuf.Len() == 0 {
t.Fatalf("expected a failure to be written to the process log")
}
if !strings.Contains(strings.ToLower(logBuf.String()), "send") && !strings.Contains(strings.ToLower(logBuf.String()), "mail") {
t.Errorf("expected the log to name the mail-send failure, got %q", logBuf.String())
}
})
t.Run("An unverified user can request a password reset exactly like a verified one", func(t *testing.T) {
+35 -2
View File
@@ -3,10 +3,22 @@ package test
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"log"
"os"
"strings"
"testing"
"time"
)
func captureRequestPasswordResetLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
return &buf
}
func newRequestPasswordResetTestUserRepo() *mockUserRepo {
repo := newMockUserRepo()
repo.users[3] = user.User{ID: 3, Email: "alice@example.com"}
@@ -79,6 +91,7 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
mailRepo := newMockMailRepo()
mailRepo.fail.failNext("Send")
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
logBuf := captureRequestPasswordResetLog(t)
err := uc.RequestPasswordReset("alice@example.com")
if err != nil {
@@ -87,12 +100,19 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
if repo.updatePasswordResetCodeCalls != 1 {
t.Errorf("expected UpdatePasswordResetCode to be called once, got %d", repo.updatePasswordResetCodeCalls)
}
if logBuf.Len() == 0 {
t.Fatalf("expected a failure to be logged")
}
if !strings.Contains(strings.ToLower(logBuf.String()), "send") && !strings.Contains(strings.ToLower(logBuf.String()), "mail") {
t.Errorf("expected the log to identify a mail-send failure, got %q", logBuf.String())
}
})
t.Run("The use case swallows a hash failure and returns nil without storing or sending", func(t *testing.T) {
t.Run("The use case logs and swallows a hashing failure", func(t *testing.T) {
repo := newRequestPasswordResetTestUserRepo()
mailRepo := newMockMailRepo()
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, mailRepo, "https://cms.example.com")
logBuf := captureRequestPasswordResetLog(t)
err := uc.RequestPasswordReset("alice@example.com")
if err != nil {
@@ -104,13 +124,20 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
if len(mailRepo.SentEmails) != 0 {
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
}
if logBuf.Len() == 0 {
t.Fatalf("expected a failure to be logged")
}
if !strings.Contains(strings.ToLower(logBuf.String()), "hash") {
t.Errorf("expected the log to identify a hashing failure, got %q", logBuf.String())
}
})
t.Run("The use case swallows a store failure and returns nil without sending", func(t *testing.T) {
t.Run("The use case logs and swallows a repository failure while storing the reset code", func(t *testing.T) {
repo := newRequestPasswordResetTestUserRepo()
repo.fail.failNext("UpdatePasswordResetCode")
mailRepo := newMockMailRepo()
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
logBuf := captureRequestPasswordResetLog(t)
err := uc.RequestPasswordReset("alice@example.com")
if err != nil {
@@ -119,6 +146,12 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
if len(mailRepo.SentEmails) != 0 {
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
}
if logBuf.Len() == 0 {
t.Fatalf("expected a failure to be logged")
}
if !strings.Contains(strings.ToLower(logBuf.String()), "updatepasswordresetcode") && !strings.Contains(strings.ToLower(logBuf.String()), "store") {
t.Errorf("expected the log to identify a storage failure, got %q", logBuf.String())
}
})
t.Run("An unverified user can request a password reset exactly like a verified one", func(t *testing.T) {
-18
View File
@@ -164,22 +164,4 @@ func TestResetPasswordUseCase(t *testing.T) {
}
})
t.Run("The generic reset-code update stores an empty code as empty instead of hashing it", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[3] = user.User{ID: 3, Email: "alice@example.com"}
hasher := &stubHasher{}
uc := useCases.NewUpdateUserUseCase(repo, hasher)
_, err := uc.UpdatePasswordResetCode(3, "", time.Time{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if hasher.hashCalls != 0 {
t.Errorf("expected Hash to never be called, got %d calls", hasher.hashCalls)
}
if repo.users[3].PasswordResetCode != "" {
t.Errorf("expected stored PasswordResetCode to be empty, got %q", repo.users[3].PasswordResetCode)
}
})
}
@@ -145,10 +145,8 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
UpdateUserUseCase: useCases.NewUpdateUserUseCase(userRepo, hasher),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo),
-95
View File
@@ -588,101 +588,6 @@ func TestListUsersReturnsEveryUser(t *testing.T) {
}
}
func TestUpdateUserDelegatesEachOperation(t *testing.T) {
newRepo := func() *mockUserRepo {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
return repo
}
t.Run("UpdateVerificationStatus_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
result, err := uc.UpdateVerificationStatus(1, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 || !result.IsVerified {
t.Errorf("unexpected result: %+v", result)
}
})
t.Run("UpdateVerificationStatus_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdateVerificationStatus(9, true)
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
t.Run("UpdatePassword_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
result, err := uc.UpdatePassword(1, "newpassword123")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("UpdatePassword_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdatePassword(9, "newpassword123")
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
t.Run("UpdatePasswordResetCode_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
result, err := uc.UpdatePasswordResetCode(1, "reset-code-123", time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.ID != 1 {
t.Errorf("expected user id 1, got %d", result.ID)
}
})
t.Run("UpdatePasswordResetCode_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdatePasswordResetCode(9, "reset-code-123", time.Now().Add(time.Hour))
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
}
func TestDeleteUserReportsMissingUser(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
uc := useCases.NewDeleteUserUseCase(repo)
err := uc.DeleteUser(1)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
t.Run("id_9", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1}
uc := useCases.NewDeleteUserUseCase(repo)
err := uc.DeleteUser(9)
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
}
func TestSendMailForwardsArgumentsVerbatim(t *testing.T) {
cases := []struct {
receiver string
-14
View File
@@ -233,17 +233,3 @@ func TestCreateUser(t *testing.T) {
t.Errorf("Verification code was not generated")
}
}
func TestUpdateUserVerification(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, IsVerified: false}
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
res, err := uc.UpdateVerificationStatus(1, true)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if !res.IsVerified {
t.Errorf("User should be verified")
}
}
@@ -196,6 +196,8 @@ func TestVerifiedAccountRequiredHttp(t *testing.T) {
{"a validly signed jwt cookie whose claims carry no user_id key at all", map[string]any{"role": "user"}},
{`a validly signed jwt cookie whose user_id claim is the string "one"`, map[string]any{"user_id": "one"}},
{"a validly signed jwt cookie whose user_id claim is the number 999, which does not match any user", map[string]any{"user_id": uint32(999)}},
{"a validly signed jwt cookie whose user_id claim is the number -1", map[string]any{"user_id": -1.0}},
{"a validly signed jwt cookie whose user_id claim is the number 4294967296 (one past the uint32 max)", map[string]any{"user_id": float64(4294967296)}},
}
for _, tc := range unresolvableTokenCases {
t.Run("A token that resolves to no usable account still answers 401, never 403: "+tc.name, func(t *testing.T) {
@@ -217,6 +219,28 @@ func TestVerifiedAccountRequiredHttp(t *testing.T) {
})
}
t.Run("A fractional user_id claim is never truncated into impersonating a different real user", func(t *testing.T) {
articleRepo := newMockArticleRepo()
userRepo := newMockUserRepo()
userRepo.users[7] = domainUser.User{ID: 7, IsVerified: true}
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(http.MethodPost, "/v1/article/", `{"title": "Valid Title", "body": "Some content"}`)
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": 7.5}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d, body %q", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(articleRepo.articles) != 0 {
t.Errorf("expected no article to be created, got %+v", articleRepo.articles)
}
})
noCookieCases := []struct {
method string
path string