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, token *jwtauth.JWTAuth) *chi.Mux { hasher := &stubHasher{} verificationHandler := auth.NewVerificationHandler( useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder), useCases.NewCancelRegistrationUseCase(userRepo), 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", verificationHandler.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, 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, 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, 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, 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, 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, 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, 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, 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, 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()) } }) }