refac: reamaining auth surface rewrite, T0
This commit is contained in:
1 parent
6d510d2805
commit
aa865af2e9
50 files changed
+3723
-149
No files matched your search
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user