refac: reamaining auth surface rewrite, T0

This commit is contained in:
fsylvain committed 2026-09-24 05:11:49 +02:00
1 parent 6d510d2805
commit aa865af2e9
50 files changed
+3723 -149

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
package test
import (
"RenewCMS/api/handlers/auth"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newAccountVerificationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux {
hasher := &stubHasher{}
authHandler := auth.NewHandler(
token,
useCases.NewGetUserUseCase(userRepo),
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Post("/verify", authHandler.Verify)
})
})
})
return r
}
func newAccountVerificationTestUserRepo() *mockUserRepo {
repo := newMockUserRepo()
repo.users[7] = user.User{
ID: 7,
Username: "alice",
IsVerified: false,
VerificationCode: "hashed:abc123",
VerificationExpiration: time.Now().Add(time.Hour),
}
repo.nextID = 7
return repo
}
func sendVerifyRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[string]any, code string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{"code": code})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
if claims != nil {
_, tokenString, _ := token.Encode(claims)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestAccountVerificationHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("A logged-in user verifies their account with the correct emailed code", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
rec := sendVerifyRequest(router, token, map[string]any{"user_id": uint32(7)}, "abc123")
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
var got user.User
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !got.IsVerified {
t.Errorf("expected is_verified true")
}
if got.VerificationCode != "" {
t.Errorf("expected verification_code empty, got %q", got.VerificationCode)
}
stored := repo.users[7]
if !stored.IsVerified || stored.VerificationCode != "" {
t.Errorf("expected stored record updated, got %+v", stored)
}
})
t.Run("A wrong code is refused and nothing changes", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
rec := sendVerifyRequest(router, token, map[string]any{"user_id": uint32(7)}, "wrong-code")
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.VerificationFailedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
stored := repo.users[7]
if stored.IsVerified || stored.VerificationCode != "hashed:abc123" {
t.Errorf("expected stored record unchanged, got %+v", stored)
}
})
t.Run("An expired code is refused and nothing changes", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
expired := repo.users[7]
expired.VerificationExpiration = time.Now().Add(-time.Hour)
repo.users[7] = expired
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
rec := sendVerifyRequest(router, token, map[string]any{"user_id": uint32(7)}, "abc123")
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.VerificationFailedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
stored := repo.users[7]
if stored.IsVerified || stored.VerificationCode != "hashed:abc123" {
t.Errorf("expected stored record unchanged, got %+v", stored)
}
})
t.Run("The code is cleared once used, so it cannot be replayed", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
first := sendVerifyRequest(router, token, map[string]any{"user_id": uint32(7)}, "abc123")
if first.Code != http.StatusOK {
t.Fatalf("expected first status 200, got %d: %s", first.Code, first.Body.String())
}
second := sendVerifyRequest(router, token, map[string]any{"user_id": uint32(7)}, "abc123")
if second.Code != http.StatusForbidden {
t.Fatalf("expected second status 403, got %d: %s", second.Code, second.Body.String())
}
if second.Body.String() != apperr.VerificationFailedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", second.Body.String())
}
})
t.Run("A token without a valid user_id returns 401 instead of crashing", func(t *testing.T) {
cases := []struct {
name string
claims map[string]any
}{
{"no user_id key at all", map[string]any{"role": "user"}},
{"user_id is a string", map[string]any{"user_id": "seven"}},
{"user_id does not match any user", map[string]any{"user_id": uint32(999)}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
rec := sendVerifyRequest(router, token, tc.claims, "abc123")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
stored := repo.users[7]
if stored.IsVerified {
t.Errorf("expected stored record unchanged, got %+v", stored)
}
})
}
})
t.Run("A repository failure unrelated to the code returns 500", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
repo.fail.failNext("ClearVerificationCode")
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
rec := sendVerifyRequest(router, token, map[string]any{"user_id": uint32(7)}, "abc123")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "Internal server error\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("A malformed request body returns 400", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader([]byte("not-json")))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.BodyErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("A missing code fails validation and returns 400", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
rec := sendVerifyRequest(router, token, map[string]any{"user_id": uint32(7)}, "")
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("A request with no jwt cookie is answered 401 by jwtauth itself", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, newMockMailRepo(), token)
rec := sendVerifyRequest(router, token, nil, "abc123")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
})
}
+81
View File
@@ -0,0 +1,81 @@
package test
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"testing"
"time"
)
func TestAccountVerificationUseCase(t *testing.T) {
t.Run("The use case itself enforces the rule, independently of any handler", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[3] = user.User{
ID: 3,
VerificationCode: "hashed:right-code",
VerificationExpiration: time.Now().Add(time.Hour),
}
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{})
_, err := uc.Verify(3, "wrong-code")
if !errors.Is(err, user.ErrInvalidVerificationCode) {
t.Fatalf("expected ErrInvalidVerificationCode, got %v", err)
}
if repo.clearVerificationCodeCalls != 0 {
t.Errorf("expected ClearVerificationCode to never be called, got %d calls", repo.clearVerificationCodeCalls)
}
})
t.Run("The use case rejects an expired code independently of any handler", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[3] = user.User{
ID: 3,
VerificationCode: "hashed:right-code",
VerificationExpiration: time.Now().Add(-time.Hour),
}
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{})
_, err := uc.Verify(3, "right-code")
if !errors.Is(err, user.ErrVerificationCodeExpired) {
t.Fatalf("expected ErrVerificationCodeExpired, got %v", err)
}
if repo.clearVerificationCodeCalls != 0 {
t.Errorf("expected ClearVerificationCode to never be called, got %d calls", repo.clearVerificationCodeCalls)
}
})
t.Run("The use case succeeds and clears the code independently of any handler", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[3] = user.User{
ID: 3,
VerificationCode: "hashed:right-code",
VerificationExpiration: time.Now().Add(time.Hour),
}
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{})
result, err := uc.Verify(3, "right-code")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !result.IsVerified {
t.Errorf("expected IsVerified true")
}
if result.VerificationCode != "" {
t.Errorf("expected VerificationCode empty, got %q", result.VerificationCode)
}
if repo.clearVerificationCodeCalls != 1 {
t.Errorf("expected ClearVerificationCode to be called exactly once, got %d calls", repo.clearVerificationCodeCalls)
}
if len(repo.clearVerificationCodeUserIds) != 1 || repo.clearVerificationCodeUserIds[0] != 3 {
t.Errorf("expected ClearVerificationCode to be called with userId 3, got %v", repo.clearVerificationCodeUserIds)
}
stored := repo.users[3]
if !stored.IsVerified || stored.VerificationCode != "" {
t.Errorf("expected stored record updated, got %+v", stored)
}
})
}
+6
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
@@ -18,12 +19,16 @@ import (
)
func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
@@ -34,6 +39,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
r.Get("/", articleHandler.ListArticles)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Get("/{id}", articleHandler.GetArticle)
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
+6
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
@@ -21,12 +22,16 @@ import (
)
func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo),
useCases.NewGetArticleUseCase(repo),
useCases.NewListArticlesUseCase(repo),
useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(repo),
getUserUseCase,
)
r := chi.NewRouter()
@@ -34,6 +39,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
r.Post("/", articleHandler.PostArticle)
})
@@ -0,0 +1,230 @@
package test
import (
"RenewCMS/api/handlers/auth"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux {
hasher := &stubHasher{}
authHandler := auth.NewHandler(
token,
useCases.NewGetUserUseCase(userRepo),
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Post("/register", authHandler.Register)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Post("/verify", authHandler.Verify)
r.Delete("/register", authHandler.CancelRegistration)
})
})
})
return r
}
func newCancelRegistrationTestUserRepo(isVerified bool) *mockUserRepo {
repo := newMockUserRepo()
repo.users[7] = user.User{
ID: 7,
Username: "alice",
Email: "alice@example.com",
IsVerified: isVerified,
}
repo.nextID = 7
return repo
}
func sendCancelRegistrationRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[string]any) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, "/v1/auth/register", nil)
if claims != nil {
_, tokenString, _ := token.Encode(claims)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func jwtSetCookie(t *testing.T, rec *httptest.ResponseRecorder) (*http.Cookie, bool) {
t.Helper()
for _, value := range rec.Result().Header.Values("Set-Cookie") {
cookie, err := http.ParseSetCookie(value)
if err != nil {
t.Fatalf("failed to parse Set-Cookie header %q: %v", value, err)
}
if cookie.Name == "jwt" {
return cookie, true
}
}
return nil, false
}
func TestCancelPendingRegistrationHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("An unverified user cancels their pending registration", func(t *testing.T) {
repo := newCancelRegistrationTestUserRepo(false)
router := newCancelRegistrationRouter(repo, newMockMailRepo(), token)
rec := sendCancelRegistrationRequest(router, token, map[string]any{"user_id": uint32(7)})
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if _, ok := body["message"]; !ok {
t.Errorf("expected a message field in the response body")
}
cookie, ok := jwtSetCookie(t, rec)
if !ok {
t.Fatalf("expected a jwt Set-Cookie header")
}
if cookie.Value != "" || cookie.MaxAge >= 0 {
t.Errorf("expected the jwt cookie to be cleared, got %+v", cookie)
}
if _, err := repo.Get(7); err == nil {
t.Errorf("expected user 7 to no longer exist")
} else if err != user.ErrUserNotFound {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
})
t.Run("Cancelling frees the username and email for a new registration", func(t *testing.T) {
repo := newCancelRegistrationTestUserRepo(false)
router := newCancelRegistrationRouter(repo, newMockMailRepo(), token)
cancelRec := sendCancelRegistrationRequest(router, token, map[string]any{"user_id": uint32(7)})
if cancelRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", cancelRec.Code, cancelRec.Body.String())
}
registerRec := registerRequest(t, router, "alice", "hunter2example12", "alice@example.com")
if registerRec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", registerRec.Code, registerRec.Body.String())
}
if _, ok := jwtSetCookie(t, registerRec); !ok {
t.Fatalf("expected a jwt Set-Cookie header on registration")
}
newUser, err := repo.GetByUsername("alice")
if err != nil {
t.Fatalf("expected alice to be registered again: %v", err)
}
if newUser.ID == 7 {
t.Errorf("expected a new id different from 7, got %d", newUser.ID)
}
})
t.Run("An already-verified user cannot cancel their account this way", func(t *testing.T) {
repo := newCancelRegistrationTestUserRepo(true)
router := newCancelRegistrationRouter(repo, newMockMailRepo(), token)
rec := sendCancelRegistrationRequest(router, token, map[string]any{"user_id": uint32(7)})
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.AlreadyVerifiedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if _, ok := jwtSetCookie(t, rec); ok {
t.Errorf("expected no jwt Set-Cookie header")
}
stored, err := repo.Get(7)
if err != nil {
t.Fatalf("expected user 7 to still exist: %v", err)
}
if !stored.IsVerified {
t.Errorf("expected stored user to remain verified")
}
})
t.Run("A token without a valid user_id returns 401 instead of crashing", func(t *testing.T) {
cases := []struct {
name string
claims map[string]any
}{
{"no user_id key at all", map[string]any{"role": "user"}},
{"user_id is a string", map[string]any{"user_id": "seven"}},
{"user_id does not match any user", map[string]any{"user_id": uint32(999)}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := newCancelRegistrationTestUserRepo(false)
router := newCancelRegistrationRouter(repo, newMockMailRepo(), token)
rec := sendCancelRegistrationRequest(router, token, tc.claims)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if _, ok := jwtSetCookie(t, rec); ok {
t.Errorf("expected no jwt Set-Cookie header")
}
if _, err := repo.Get(7); err != nil {
t.Errorf("expected user 7 to still exist: %v", err)
}
})
}
})
t.Run("A repository failure unrelated to verification returns 500", func(t *testing.T) {
repo := newCancelRegistrationTestUserRepo(false)
repo.fail.failNext("Delete")
router := newCancelRegistrationRouter(repo, newMockMailRepo(), token)
rec := sendCancelRegistrationRequest(router, token, map[string]any{"user_id": uint32(7)})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "Internal server error\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("A request with no jwt cookie never reaches the handler", func(t *testing.T) {
repo := newCancelRegistrationTestUserRepo(false)
router := newCancelRegistrationRouter(repo, newMockMailRepo(), token)
rec := sendCancelRegistrationRequest(router, token, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := repo.Get(7); err != nil {
t.Errorf("expected user 7 to still exist: %v", err)
}
})
}
+51
View File
@@ -0,0 +1,51 @@
package test
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"testing"
)
func TestCancelRegistrationUseCase(t *testing.T) {
t.Run("The use case deletes an unverified user, independently of any handler", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[3] = user.User{ID: 3, IsVerified: false}
uc := useCases.NewCancelRegistrationUseCase(repo)
err := uc.CancelRegistration(3)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if _, ok := repo.users[3]; ok {
t.Errorf("expected user 3 to be removed from the repository")
}
})
t.Run("The use case refuses to delete an already-verified user, independently of any handler", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[3] = user.User{ID: 3, IsVerified: true}
uc := useCases.NewCancelRegistrationUseCase(repo)
err := uc.CancelRegistration(3)
if !errors.Is(err, user.ErrAlreadyVerified) {
t.Fatalf("expected user.ErrAlreadyVerified, got %v", err)
}
if _, ok := repo.users[3]; !ok {
t.Errorf("expected user 3 to still be present in the repository")
}
})
t.Run("The use case reports an unknown user, independently of any handler", func(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewCancelRegistrationUseCase(repo)
err := uc.CancelRegistration(42)
if !errors.Is(err, user.ErrUserNotFound) {
t.Fatalf("expected user.ErrUserNotFound, got %v", err)
}
})
}
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
@@ -82,12 +83,16 @@ func TestDeleteArticleUseCaseCascadesImageDeletion(t *testing.T) {
}
func newDeleteArticleCascadesImagesRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
@@ -95,6 +100,7 @@ func newDeleteArticleCascadesImagesRouter(articleRepo *mockArticleRepo, imageRep
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
r.Delete("/{id}", articleHandler.DeleteArticle)
})
+6
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
@@ -19,12 +20,16 @@ import (
)
func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
@@ -34,6 +39,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
r.Use(jwtauth.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Patch("/{id}", articleHandler.UpdateArticleBody)
})
})
+10 -1
View File
@@ -14,6 +14,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
@@ -95,7 +96,7 @@ func TestUpdateUserUseCaseHashesUpdatedValues(t *testing.T) {
method: "UpdatePasswordResetCode",
plaintext: "reset-code-123",
run: func(uc *useCases.UpdateUserUseCase, plaintext string) (user.User, error) {
return uc.UpdatePasswordResetCode(1, plaintext)
return uc.UpdatePasswordResetCode(1, plaintext, time.Now().Add(time.Hour))
},
stored: func(u user.User) string { return u.PasswordResetCode },
},
@@ -218,6 +219,10 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
@@ -238,6 +243,10 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
+11 -3
View File
@@ -3,6 +3,7 @@ package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
@@ -124,12 +125,14 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
}
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
hasher := hashing.NewBcryptHasher()
authHandler := auth.NewHandler(
@@ -138,6 +141,10 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
@@ -146,6 +153,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
r.Get("/{id}", articleHandler.GetArticle)
})
@@ -178,7 +186,7 @@ func TestNotFoundErrorsHttp(t *testing.T) {
}
t.Run("Fetching a nonexistent article answers 404, not 500", func(t *testing.T) {
router := newNotFoundErrorsRouter(newArticleRepo(), newMockUserRepo(), token)
router := newNotFoundErrorsRouter(newArticleRepo(), newVerifiedUserRepo(), token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/9999", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
@@ -193,7 +201,7 @@ func TestNotFoundErrorsHttp(t *testing.T) {
})
t.Run("Fetching an offline article still answers 404 with the same message", func(t *testing.T) {
router := newNotFoundErrorsRouter(newArticleRepo(), newMockUserRepo(), token)
router := newNotFoundErrorsRouter(newArticleRepo(), newVerifiedUserRepo(), token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/2", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
@@ -208,7 +216,7 @@ func TestNotFoundErrorsHttp(t *testing.T) {
})
t.Run("Fetching an online article still answers 200", func(t *testing.T) {
router := newNotFoundErrorsRouter(newArticleRepo(), newMockUserRepo(), token)
router := newNotFoundErrorsRouter(newArticleRepo(), newVerifiedUserRepo(), token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/1", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
+6
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
@@ -37,12 +38,16 @@ func (w *erroringResponseWriter) Write([]byte) (int, error) {
func (w *erroringResponseWriter) WriteHeader(int) {}
func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
@@ -54,6 +59,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Get("/{id}", articleHandler.GetArticle)
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -46,6 +46,10 @@ func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, p
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
+243
View File
@@ -0,0 +1,243 @@
package test
import (
"RenewCMS/api/handlers/auth"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newRequestPasswordResetRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, publicURL string) *chi.Mux {
hasher := &stubHasher{}
token := jwtauth.New("HS256", []byte("test-secret"), nil)
authHandler := auth.NewHandler(
token,
useCases.NewGetUserUseCase(userRepo),
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Post("/password-reset", authHandler.RequestPasswordReset)
})
})
return r
}
func sendPasswordResetRequest(router *chi.Mux, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/auth/password-reset", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder
}
const requestPasswordResetGenericBody = `{"message":"If an account exists for that email, a password reset link has been sent."}`
func TestRequestPasswordResetHTTP(t *testing.T) {
validator.InitValidator()
t.Run("Requesting a reset for an existing email sends the reset email and returns the generic message", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
recorder := sendPasswordResetRequest(router, `{"email": "alice@example.com"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody {
t.Errorf("unexpected body: %s", recorder.Body.String())
}
if len(mailRepo.SentEmails) != 1 {
t.Fatalf("expected exactly one email, got %d", len(mailRepo.SentEmails))
}
if mailRepo.SentEmails[0].Address != "alice@example.com" {
t.Errorf("expected email to alice@example.com, got %s", mailRepo.SentEmails[0].Address)
}
if mailRepo.SentEmails[0].Template != "passwordReset" {
t.Errorf("expected template passwordReset, got %s", mailRepo.SentEmails[0].Template)
}
})
t.Run("Requesting a reset for an email with no account returns the identical response and sends no email", func(t *testing.T) {
userRepo := newMockUserRepo()
mailRepo := newMockMailRepo()
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
recorder := sendPasswordResetRequest(router, `{"email": "nobody@example.com"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody {
t.Errorf("unexpected body: %s", recorder.Body.String())
}
if len(mailRepo.SentEmails) != 0 {
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
}
})
t.Run("The reset email's Host reflects the injected public URL", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
sendPasswordResetRequest(router, `{"email": "alice@example.com"}`)
if len(mailRepo.SentEmails) != 1 {
t.Fatalf("expected exactly one email, got %d", len(mailRepo.SentEmails))
}
data, ok := mailRepo.SentEmails[0].Data.(useCases.MailPasswordResetData)
if !ok {
t.Fatalf("expected data of type MailPasswordResetData, got %T", mailRepo.SentEmails[0].Data)
}
if data.Host != "https://cms.example.com" {
t.Errorf("expected Host https://cms.example.com, got %s", data.Host)
}
})
t.Run("The stored reset code expires one hour after creation", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
sendPasswordResetRequest(router, `{"email": "alice@example.com"}`)
stored := userRepo.users[1]
want := time.Now().Add(time.Hour)
if stored.PasswordResetExpiration.Sub(want).Abs() > 5*time.Second {
t.Errorf("expected expiration near %v, got %v", want, stored.PasswordResetExpiration)
}
})
t.Run("A second request overwrites the first code, invalidating it", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
sendPasswordResetRequest(router, `{"email": "alice@example.com"}`)
sendPasswordResetRequest(router, `{"email": "alice@example.com"}`)
if len(mailRepo.SentEmails) != 2 {
t.Fatalf("expected exactly two emails, got %d", len(mailRepo.SentEmails))
}
firstData := mailRepo.SentEmails[0].Data.(useCases.MailPasswordResetData)
secondData := mailRepo.SentEmails[1].Data.(useCases.MailPasswordResetData)
if firstData.VerificationCode == secondData.VerificationCode {
t.Errorf("expected the second code to differ from the first")
}
hasher := &stubHasher{}
stored := userRepo.users[1]
if hasher.Compare(stored.PasswordResetCode, firstData.VerificationCode) == nil {
t.Errorf("expected the stored code to no longer match the first raw code")
}
if hasher.Compare(stored.PasswordResetCode, secondData.VerificationCode) != nil {
t.Errorf("expected the stored code to match the second raw code")
}
})
t.Run("A mail-send failure for an existing user still returns the same generic response", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
mailRepo.fail.failNext("Send")
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
recorder := sendPasswordResetRequest(router, `{"email": "alice@example.com"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody {
t.Errorf("unexpected body: %s", recorder.Body.String())
}
if recorder.Header().Get("Set-Cookie") != "" {
t.Errorf("expected no jwt cookie to be set, got %s", recorder.Header().Get("Set-Cookie"))
}
})
t.Run("An unverified user can request a password reset exactly like a verified one", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "bob", Email: "bob@example.com", IsVerified: false}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
recorder := sendPasswordResetRequest(router, `{"email": "bob@example.com"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody {
t.Errorf("unexpected body: %s", recorder.Body.String())
}
if len(mailRepo.SentEmails) != 1 {
t.Fatalf("expected exactly one email, got %d", len(mailRepo.SentEmails))
}
if mailRepo.SentEmails[0].Address != "bob@example.com" {
t.Errorf("expected email to bob@example.com, got %s", mailRepo.SentEmails[0].Address)
}
if mailRepo.SentEmails[0].Template != "passwordReset" {
t.Errorf("expected template passwordReset, got %s", mailRepo.SentEmails[0].Template)
}
})
t.Run("A malformed request is still rejected, since this is not an enumeration concern", func(t *testing.T) {
cases := []struct {
name string
body string
}{
{"missing email", `{}`},
{"malformed email", `{"email": "not-an-email"}`},
{"non-json body", `not-json`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com")
recorder := sendPasswordResetRequest(router, tc.body)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", recorder.Code)
}
if len(mailRepo.SentEmails) != 0 {
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
}
})
}
})
}
+140
View File
@@ -0,0 +1,140 @@
package test
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"testing"
"time"
)
func newRequestPasswordResetTestUserRepo() *mockUserRepo {
repo := newMockUserRepo()
repo.users[3] = user.User{ID: 3, Email: "alice@example.com"}
repo.nextID = 3
return repo
}
func TestRequestPasswordResetUseCase(t *testing.T) {
t.Run("The use case itself hashes the code, stores it with a one-hour expiration, and sends the email", func(t *testing.T) {
repo := newRequestPasswordResetTestUserRepo()
mailRepo := newMockMailRepo()
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
err := uc.RequestPasswordReset("alice@example.com")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if repo.updatePasswordResetCodeCalls != 1 {
t.Fatalf("expected UpdatePasswordResetCode to be called once, got %d", repo.updatePasswordResetCodeCalls)
}
if repo.lastUpdatePasswordResetCodeUserId != 3 {
t.Errorf("expected userId 3, got %d", repo.lastUpdatePasswordResetCodeUserId)
}
if got, want := repo.lastUpdatePasswordResetCodeExpiration, time.Now().Add(time.Hour); got.Sub(want).Abs() > 5*time.Second {
t.Errorf("expected expiration near %v, got %v", want, got)
}
if len(mailRepo.SentEmails) != 1 {
t.Fatalf("expected exactly one email, got %d", len(mailRepo.SentEmails))
}
sent := mailRepo.SentEmails[0]
if sent.Address != "alice@example.com" {
t.Errorf("expected email to alice@example.com, got %s", sent.Address)
}
if sent.Template != "passwordReset" {
t.Errorf("expected template passwordReset, got %s", sent.Template)
}
data, ok := sent.Data.(useCases.MailPasswordResetData)
if !ok {
t.Fatalf("expected data of type MailPasswordResetData, got %T", sent.Data)
}
if data.Host != "https://cms.example.com" {
t.Errorf("expected Host https://cms.example.com, got %s", data.Host)
}
if (&stubHasher{}).Compare(repo.lastUpdatePasswordResetCodeCode, data.VerificationCode) != nil {
t.Errorf("expected stored hash to match the raw code sent in the email")
}
})
t.Run("The use case swallows an unknown email and returns nil without touching the repository or mailer", func(t *testing.T) {
repo := newMockUserRepo()
mailRepo := newMockMailRepo()
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
err := uc.RequestPasswordReset("nobody@example.com")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if repo.updatePasswordResetCodeCalls != 0 {
t.Errorf("expected UpdatePasswordResetCode to never be called, got %d calls", repo.updatePasswordResetCodeCalls)
}
if len(mailRepo.SentEmails) != 0 {
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
}
})
t.Run("The use case swallows a mail repository failure and still returns nil", func(t *testing.T) {
repo := newRequestPasswordResetTestUserRepo()
mailRepo := newMockMailRepo()
mailRepo.fail.failNext("Send")
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
err := uc.RequestPasswordReset("alice@example.com")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if repo.updatePasswordResetCodeCalls != 1 {
t.Errorf("expected UpdatePasswordResetCode to be called once, got %d", repo.updatePasswordResetCodeCalls)
}
})
t.Run("The use case swallows a hash failure and returns nil without storing or sending", func(t *testing.T) {
repo := newRequestPasswordResetTestUserRepo()
mailRepo := newMockMailRepo()
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, mailRepo, "https://cms.example.com")
err := uc.RequestPasswordReset("alice@example.com")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if repo.updatePasswordResetCodeCalls != 0 {
t.Errorf("expected UpdatePasswordResetCode to never be called, got %d calls", repo.updatePasswordResetCodeCalls)
}
if len(mailRepo.SentEmails) != 0 {
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
}
})
t.Run("The use case swallows a store failure and returns nil without sending", func(t *testing.T) {
repo := newRequestPasswordResetTestUserRepo()
repo.fail.failNext("UpdatePasswordResetCode")
mailRepo := newMockMailRepo()
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
err := uc.RequestPasswordReset("alice@example.com")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if len(mailRepo.SentEmails) != 0 {
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
}
})
t.Run("An unverified user can request a password reset exactly like a verified one", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, Email: "alice@example.com", IsVerified: false}
repo.users[2] = user.User{ID: 2, Email: "bob@example.com", IsVerified: false}
repo.nextID = 2
mailRepo := newMockMailRepo()
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
err := uc.RequestPasswordReset("alice@example.com")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if len(mailRepo.SentEmails) != 1 {
t.Fatalf("expected exactly one email, got %d", len(mailRepo.SentEmails))
}
})
}
+280
View File
@@ -0,0 +1,280 @@
package test
import (
"RenewCMS/api/handlers/auth"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newResetPasswordRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
mailRepo := newMockMailRepo()
authHandler := auth.NewHandler(
token,
useCases.NewGetUserUseCase(userRepo),
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Post("/password-reset/confirm", authHandler.ResetPassword)
})
})
return r
}
func sendResetPasswordRequest(router *chi.Mux, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/auth/password-reset/confirm", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func newAliceResetPasswordRepo(expiration time.Time) *mockUserRepo {
repo := newMockUserRepo()
repo.users[1] = user.User{
ID: 1,
Username: "alice",
Email: "alice@example.com",
Password: "hashed:oldpassword",
PasswordResetCode: "hashed:reset-code-1",
PasswordResetExpiration: expiration,
}
repo.nextID = 1
return repo
}
func TestResetPasswordHTTP(t *testing.T) {
validator.InitValidator()
t.Run("A correct, unexpired code sets the new password and cannot be reused", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
firstBody := `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`
first := sendResetPasswordRequest(router, firstBody)
if first.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", first.Code, first.Body.String())
}
if first.Body.String() != `{"message":"Your password has been reset. You can now log in."}` {
t.Errorf("unexpected body: %s", first.Body.String())
}
if first.Header().Get("Set-Cookie") != "" {
t.Errorf("expected no jwt cookie, got %s", first.Header().Get("Set-Cookie"))
}
stored := userRepo.users[1]
if stored.Password != "hashed:newpassword1" {
t.Errorf("expected stored password %q, got %q", "hashed:newpassword1", stored.Password)
}
if stored.PasswordResetCode != "" {
t.Errorf("expected empty PasswordResetCode, got %q", stored.PasswordResetCode)
}
if !stored.PasswordResetExpiration.IsZero() {
t.Errorf("expected zero PasswordResetExpiration, got %v", stored.PasswordResetExpiration)
}
second := sendResetPasswordRequest(router, firstBody)
if second.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", second.Code, second.Body.String())
}
if second.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", second.Body.String())
}
if userRepo.users[1].Password != "hashed:newpassword1" {
t.Errorf("expected password to remain from the first request, got %q", userRepo.users[1].Password)
}
})
t.Run("A wrong code is refused with the generic message and nothing changes", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "wrong-code", "password": "newpassword1"}`)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
stored := userRepo.users[1]
if stored.PasswordResetCode != "hashed:reset-code-1" || stored.Password != "hashed:oldpassword" {
t.Errorf("expected record unchanged, got %+v", stored)
}
})
t.Run("An expired code is refused with the same generic message and nothing changes", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(-time.Hour))
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
stored := userRepo.users[1]
if stored.PasswordResetCode != "hashed:reset-code-1" || stored.Password != "hashed:oldpassword" {
t.Errorf("expected record unchanged, got %+v", stored)
}
})
t.Run("An unknown email is refused with the exact same status and body as a wrong code", func(t *testing.T) {
userRepo := newMockUserRepo()
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, `{"email": "nobody@example.com", "code": "any-code", "password": "newpassword1"}`)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("A user who never requested a reset gets the same refusal, since the stored code is empty", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", PasswordResetCode: ""}
userRepo.nextID = 1
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "anything", "password": "newpassword1"}`)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("A hashing failure is reported as an internal server error", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
hasher := &stubHasher{hashErr: errRepositoryFailure}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d: %s", rec.Code, rec.Body.String())
}
if userRepo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", userRepo.resetPasswordCalls)
}
})
t.Run("A repository failure while writing the reset is reported as an internal server error", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
userRepo.fail.failNext("ResetPassword")
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("A 7-character password is rejected as too short and nothing changes", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "1234567"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String())
}
if userRepo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", userRepo.resetPasswordCalls)
}
stored := userRepo.users[1]
if stored.PasswordResetCode != "hashed:reset-code-1" || stored.Password != "hashed:oldpassword" {
t.Errorf("expected record unchanged, got %+v", stored)
}
})
t.Run("An exactly-8-character password with a valid code succeeds", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "12345678"}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
stored := userRepo.users[1]
if stored.Password != "hashed:12345678" {
t.Errorf("expected stored password %q, got %q", "hashed:12345678", stored.Password)
}
if stored.PasswordResetCode != "" {
t.Errorf("expected empty PasswordResetCode, got %q", stored.PasswordResetCode)
}
})
t.Run("A malformed or too-short request is rejected before any lookup happens", func(t *testing.T) {
cases := []struct {
name string
body string
}{
{"empty body", `{}`},
{"malformed email", `{"email": "not-an-email", "code": "c", "password": "newpassword1"}`},
{"empty code", `{"email": "alice@example.com", "code": "", "password": "newpassword1"}`},
{"short password", `{"email": "alice@example.com", "code": "reset-code-1", "password": "short"}`},
{"non-json body", `not-json`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
hasher := &stubHasher{}
router := newResetPasswordRouter(userRepo, hasher)
rec := sendResetPasswordRequest(router, tc.body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String())
}
if userRepo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", userRepo.resetPasswordCalls)
}
stored := userRepo.users[1]
if stored.PasswordResetCode != "hashed:reset-code-1" || stored.Password != "hashed:oldpassword" {
t.Errorf("expected record unchanged, got %+v", stored)
}
})
}
})
}
+164
View File
@@ -0,0 +1,164 @@
package test
import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"testing"
"time"
)
func TestResetPasswordUseCase(t *testing.T) {
newRepo := func(expiration time.Time) *mockUserRepo {
repo := newMockUserRepo()
repo.users[3] = user.User{
ID: 3,
Email: "alice@example.com",
PasswordResetCode: "hashed:reset-code-1",
PasswordResetExpiration: expiration,
}
return repo
}
t.Run("The use case itself sets the password and clears the code in one write, independently of any handler", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if repo.resetPasswordCalls != 1 {
t.Fatalf("expected ResetPassword to be called exactly once, got %d", repo.resetPasswordCalls)
}
if repo.lastResetPasswordUserId != 3 {
t.Errorf("expected userId 3, got %d", repo.lastResetPasswordUserId)
}
if repo.lastResetPasswordHash != "hashed:newpassword1" {
t.Errorf("expected hash %q, got %q", "hashed:newpassword1", repo.lastResetPasswordHash)
}
})
t.Run("The use case rejects a wrong code without touching the repository's ResetPassword method", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
err := uc.ResetPassword("alice@example.com", "wrong-code", "newpassword1")
if !errors.Is(err, user.ErrInvalidPasswordResetCode) {
t.Errorf("expected user.ErrInvalidPasswordResetCode, got %v", err)
}
if repo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", repo.resetPasswordCalls)
}
})
t.Run("The use case rejects an expired code without touching the repository's ResetPassword method", func(t *testing.T) {
repo := newRepo(time.Now().Add(-time.Hour))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
if !errors.Is(err, user.ErrPasswordResetCodeExpired) {
t.Errorf("expected user.ErrPasswordResetCodeExpired, got %v", err)
}
if repo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", repo.resetPasswordCalls)
}
})
t.Run("The use case treats an unknown email the same as a wrong code", func(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
err := uc.ResetPassword("nobody@example.com", "any-code", "newpassword1")
if !errors.Is(err, user.ErrInvalidPasswordResetCode) {
t.Errorf("expected user.ErrInvalidPasswordResetCode, got %v", err)
}
if repo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", repo.resetPasswordCalls)
}
})
t.Run("The use case treats an empty stored code the same as a wrong code, never calling Compare with an empty hash", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[3] = user.User{ID: 3, Email: "alice@example.com", PasswordResetCode: ""}
hasher := &stubHasher{}
uc := useCases.NewResetPasswordUseCase(repo, hasher)
err := uc.ResetPassword("alice@example.com", "anything", "newpassword1")
if !errors.Is(err, user.ErrInvalidPasswordResetCode) {
t.Errorf("expected user.ErrInvalidPasswordResetCode, got %v", err)
}
if repo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", repo.resetPasswordCalls)
}
if hasher.compareCalls != 0 {
t.Errorf("expected Compare to never be called, got %d calls", hasher.compareCalls)
}
})
t.Run("The use case treats any GetByEmail failure, not just not-found, as an invalid code", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
repo.fail.failNextWith("GetByEmail", errors.New("db is down"))
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
if !errors.Is(err, user.ErrInvalidPasswordResetCode) {
t.Errorf("expected user.ErrInvalidPasswordResetCode, got %v", err)
}
if repo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", repo.resetPasswordCalls)
}
})
t.Run("A hashing failure propagates and the repository's ResetPassword method is never called", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
hashErr := errors.New("hash blew up")
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{hashErr: hashErr})
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
if !errors.Is(err, hashErr) {
t.Errorf("expected %v, got %v", hashErr, err)
}
if repo.resetPasswordCalls != 0 {
t.Errorf("expected ResetPassword to never be called, got %d", repo.resetPasswordCalls)
}
})
t.Run("A failure in the repository's ResetPassword method propagates", func(t *testing.T) {
repo := newRepo(time.Now().Add(time.Hour))
repo.fail.failNext("ResetPassword")
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected %v, got %v", errRepositoryFailure, err)
}
})
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)
}
})
}
+20 -15
View File
@@ -42,6 +42,10 @@ func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mock
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
isProduction,
)
@@ -133,21 +137,22 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
return &api.UseCases{
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
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),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(imageRepo),
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
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),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(imageRepo),
}
}
@@ -14,6 +14,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
@@ -135,7 +136,7 @@ func TestUnverifiedUsersAndReregistrationRepository(t *testing.T) {
t.Fatalf("expected bob creation to succeed, got %v", err)
}
_, err = repo.UpdatePasswordResetCode(bob.ID, "alice-code")
_, err = repo.UpdatePasswordResetCode(bob.ID, "alice-code", time.Now().Add(time.Hour))
if err == nil {
t.Fatal("expected UpdatePasswordResetCode for bob to fail because the code is already active for alice")
}
@@ -158,6 +159,10 @@ func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *jwtauth.JWTA
useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewCancelRegistrationUseCase(userRepo),
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewResetPasswordUseCase(userRepo, hasher),
true,
)
+5
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/handlers/image"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
@@ -21,6 +22,9 @@ import (
)
func newUploadArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
imageHandler := image.NewHandler(
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
@@ -34,6 +38,7 @@ func newUploadArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockIm
r.Use(jwtauth.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/{id}/image", imageHandler.PostImage)
})
})
+2 -2
View File
@@ -640,7 +640,7 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
t.Run("UpdatePasswordResetCode_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
result, err := uc.UpdatePasswordResetCode(1, "reset-code-123")
result, err := uc.UpdatePasswordResetCode(1, "reset-code-123", time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -652,7 +652,7 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
t.Run("UpdatePasswordResetCode_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdatePasswordResetCode(9, "reset-code-123")
_, 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)
}
+64 -5
View File
@@ -6,13 +6,17 @@ import (
"errors"
"sort"
"testing"
"time"
)
type stubHasher struct {
hashErr error
hashErr error
hashCalls int
compareCalls int
}
func (s *stubHasher) Hash(value string) (string, error) {
s.hashCalls++
if s.hashErr != nil {
return "", s.hashErr
}
@@ -20,6 +24,7 @@ func (s *stubHasher) Hash(value string) (string, error) {
}
func (s *stubHasher) Compare(hashed string, value string) error {
s.compareCalls++
if hashed == "hashed:"+value {
return nil
}
@@ -27,15 +32,30 @@ func (s *stubHasher) Compare(hashed string, value string) error {
}
type mockUserRepo struct {
users map[uint32]user.User
nextID uint32
fail failSwitch
users map[uint32]user.User
nextID uint32
fail failSwitch
clearVerificationCodeCalls int
clearVerificationCodeUserIds []uint32
updatePasswordResetCodeCalls int
lastUpdatePasswordResetCodeUserId uint32
lastUpdatePasswordResetCodeCode string
lastUpdatePasswordResetCodeExpiration time.Time
resetPasswordCalls int
lastResetPasswordUserId uint32
lastResetPasswordHash string
}
func newMockUserRepo() *mockUserRepo {
return &mockUserRepo{users: make(map[uint32]user.User)}
}
func newVerifiedUserRepo() *mockUserRepo {
repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, IsVerified: true}
return repo
}
func (m *mockUserRepo) sortedIds() []uint32 {
ids := make([]uint32, 0, len(m.users))
for id := range m.users {
@@ -137,7 +157,11 @@ func (m *mockUserRepo) UpdatePassword(userId uint32, password string) (user.User
return u, nil
}
func (m *mockUserRepo) UpdatePasswordResetCode(userId uint32, code string) (user.User, error) {
func (m *mockUserRepo) UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (user.User, error) {
m.updatePasswordResetCodeCalls++
m.lastUpdatePasswordResetCodeUserId = userId
m.lastUpdatePasswordResetCodeCode = code
m.lastUpdatePasswordResetCodeExpiration = expiration
if err := m.fail.trigger("UpdatePasswordResetCode"); err != nil {
return user.User{}, err
}
@@ -146,6 +170,41 @@ func (m *mockUserRepo) UpdatePasswordResetCode(userId uint32, code string) (user
return user.User{}, user.ErrUserNotFound
}
u.PasswordResetCode = code
u.PasswordResetExpiration = expiration
m.users[userId] = u
return u, nil
}
func (m *mockUserRepo) ClearVerificationCode(userId uint32) (user.User, error) {
m.clearVerificationCodeCalls++
m.clearVerificationCodeUserIds = append(m.clearVerificationCodeUserIds, userId)
if err := m.fail.trigger("ClearVerificationCode"); err != nil {
return user.User{}, err
}
u, ok := m.users[userId]
if !ok {
return user.User{}, user.ErrUserNotFound
}
u.IsVerified = true
u.VerificationCode = ""
m.users[userId] = u
return u, nil
}
func (m *mockUserRepo) ResetPassword(userId uint32, hashedPassword string) (user.User, error) {
m.resetPasswordCalls++
m.lastResetPasswordUserId = userId
m.lastResetPasswordHash = hashedPassword
if err := m.fail.trigger("ResetPassword"); err != nil {
return user.User{}, err
}
u, ok := m.users[userId]
if !ok {
return user.User{}, user.ErrUserNotFound
}
u.Password = hashedPassword
u.PasswordResetCode = ""
u.PasswordResetExpiration = time.Time{}
m.users[userId] = u
return u, nil
}
+352
View File
@@ -0,0 +1,352 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/image"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
imageHandler := image.NewHandler(
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Get("/", articleHandler.ListArticles)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Get("/{id}", articleHandler.GetArticle)
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/image", imageHandler.PostImage)
})
})
})
return r
}
func seedVerifiedAccountRequiredArticleRepo() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
return repo
}
func seedVerifiedAccountRequiredUserRepo(isVerified bool) *mockUserRepo {
repo := newMockUserRepo()
repo.users[1] = domainUser.User{ID: 1, IsVerified: isVerified}
return repo
}
func mintVerifiedAccountRequiredCookie(t *testing.T, token *jwtauth.JWTAuth, claims map[string]any) *http.Cookie {
t.Helper()
_, tokenString, err := token.Encode(claims)
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
return &http.Cookie{Name: "jwt", Value: tokenString}
}
func newVerifiedAccountRequiredRequest(method, target string, body string) *http.Request {
if body == "" {
return httptest.NewRequest(method, target, nil)
}
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
return req
}
func TestVerifiedAccountRequiredHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
protectedRouteCases := []struct {
method string
path string
body string
}{
{http.MethodGet, "/v1/article/1", ""},
{http.MethodPost, "/v1/article/", `{"title": "Valid Title", "body": "Some content"}`},
{http.MethodDelete, "/v1/article/1", ""},
{http.MethodPatch, "/v1/article/1", `{"body": "Updated"}`},
{http.MethodPatch, "/v1/article/1/publish", ""},
{http.MethodPatch, "/v1/article/1/unpublish", ""},
}
for _, tc := range protectedRouteCases {
t.Run("An authenticated but unverified user is refused on every protected article route: "+tc.method+" "+tc.path, func(t *testing.T) {
articleRepo := seedVerifiedAccountRequiredArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(false)
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(tc.method, tc.path, tc.body)
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1)}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d, body %q", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if got := articleRepo.articles[1]; got.Title != "Backyard Renovation Update" || got.Body != "" || got.IsOnline {
t.Errorf("expected article 1 to be untouched, got %+v", got)
}
})
}
t.Run("An unverified user is also refused when attaching an image", func(t *testing.T) {
articleRepo := seedVerifiedAccountRequiredArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(false)
imageRepo := newMockImageRepo()
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, imageRepo, token)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="file"; filename="before.png"`},
"Content-Type": {"image/png"},
})
if err != nil {
t.Fatalf("failed to create multipart part: %v", err)
}
if _, err := io.WriteString(part, "fake-image-data"); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/article/1/image", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1)}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d, body %q", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if imageRepo.images == nil {
t.Fatalf("expected imageRepo.images to be initialized")
}
if len(imageRepo.images) != 0 {
t.Errorf("expected the image repository's Create to never be called, got %d images", len(imageRepo.images))
}
})
t.Run("A verified user still exercises the protected routes exactly as before", func(t *testing.T) {
articleRepo := newMockArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(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": uint32(1)}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String())
}
var got domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got.Title != "Valid Title" || got.Body != "Some content" {
t.Errorf("unexpected article: %+v", got)
}
})
unresolvableTokenCases := []struct {
name string
claims map[string]any
}{
{"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)}},
}
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) {
articleRepo := seedVerifiedAccountRequiredArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(false)
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(http.MethodPost, "/v1/article/", `{"title": "Valid Title", "body": "Some content"}`)
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, tc.claims))
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())
}
})
}
noCookieCases := []struct {
method string
path string
}{
{http.MethodGet, "/v1/article/1"},
{http.MethodPost, "/v1/article/"},
}
for _, tc := range noCookieCases {
t.Run("A request with no jwt cookie is still refused with 401, never 403: "+tc.method+" "+tc.path, func(t *testing.T) {
articleRepo := seedVerifiedAccountRequiredArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(false)
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(tc.method, tc.path, "")
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())
}
})
}
t.Run("A repository failure while resolving the caller's verification status answers 500", func(t *testing.T) {
articleRepo := seedVerifiedAccountRequiredArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(false)
userRepo.fail.failNext("Get")
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(http.MethodDelete, "/v1/article/1", "")
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1)}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d, body %q", rec.Code, rec.Body.String())
}
if rec.Body.String() != "Internal server error\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if _, exists := articleRepo.articles[1]; !exists {
t.Errorf("expected article 1 to still exist")
}
})
seedListingArticleRepo := func() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", IsOnline: true}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Draft: Kitchen Remodel", IsOnline: false}
return repo
}
assertOnlyArticleOneListed := func(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
}
var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(got) != 1 || got[0].ID != 1 {
t.Errorf("expected exactly the article with id 1, got %+v", got)
}
}
t.Run("Listing falls back to the online-only list for an authenticated but unverified caller", func(t *testing.T) {
articleRepo := seedListingArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(false)
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(http.MethodGet, "/v1/article/", "")
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1)}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assertOnlyArticleOneListed(t, rec)
})
t.Run("Listing still returns every article for an authenticated and verified caller", func(t *testing.T) {
articleRepo := seedListingArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(true)
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(http.MethodGet, "/v1/article/", "")
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1)}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
}
var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
ids := map[uint32]bool{}
for _, a := range got {
ids[a.ID] = true
}
if !ids[1] || !ids[2] {
t.Errorf("expected ids 1 and 2, got %+v", got)
}
})
t.Run("Listing falls back to the online-only list when the caller's account no longer exists", func(t *testing.T) {
articleRepo := seedListingArticleRepo()
userRepo := newMockUserRepo()
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(http.MethodGet, "/v1/article/", "")
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(999)}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assertOnlyArticleOneListed(t, rec)
})
t.Run("Listing falls back to the online-only list rather than failing when the user lookup errors", func(t *testing.T) {
articleRepo := seedListingArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(true)
userRepo.fail.failNext("Get")
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, newMockImageRepo(), token)
req := newVerifiedAccountRequiredRequest(http.MethodGet, "/v1/article/", "")
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1)}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assertOnlyArticleOneListed(t, rec)
})
}
+9 -2
View File
@@ -2,6 +2,7 @@ package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
@@ -14,6 +15,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
@@ -125,7 +127,7 @@ func TestTheUserRepositoryReportsAMissingUserAsADomainErrorOnAWrite(t *testing.T
t.Run("UpdatePasswordResetCode and code newHashedResetCode", func(t *testing.T) {
repo := newSeededUserRepository(t)
_, err := repo.UpdatePasswordResetCode(9, "newHashedResetCode")
_, err := repo.UpdatePasswordResetCode(9, "newHashedResetCode", time.Now().Add(time.Hour))
assertWriteResultIsUserNotFound(t, err)
})
@@ -179,7 +181,7 @@ func TestTheFakesMirrorTheRealRepositoriesOnEveryWriteNotFoundPath(t *testing.T)
if _, err := userRepo.UpdatePassword(9, "newHashedPassword"); !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
if _, err := userRepo.UpdatePasswordResetCode(9, "newHashedResetCode"); !errors.Is(err, domainUser.ErrUserNotFound) {
if _, err := userRepo.UpdatePasswordResetCode(9, "newHashedResetCode", time.Now().Add(time.Hour)); !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
}
if err := userRepo.Delete(9); !errors.Is(err, domainUser.ErrUserNotFound) {
@@ -188,12 +190,16 @@ func TestTheFakesMirrorTheRealRepositoriesOnEveryWriteNotFoundPath(t *testing.T)
}
func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
@@ -201,6 +207,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
r.Delete("/{id}", articleHandler.DeleteArticle)
})