package test import ( "RenewCMS/api/handlers/article" "RenewCMS/api/middleware" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/infrastructure/markdown" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" ) func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo()) renderer := markdown.NewConverter() articleHandler := article.NewHandler( useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, renderer), useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(jwtauth.Verifier(token)) r.Group(func(r chi.Router) { r.Use(jwtauth.Authenticator(token)) r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Post("/", articleHandler.PostArticle) r.Get("/{id}", articleHandler.GetArticle) r.Patch("/{id}", articleHandler.UpdateArticleBody) }) }) }) }) return r } func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *jwtauth.JWTAuth, method, target, payload string) *httptest.ResponseRecorder { t.Helper() req := newEditArticleBodyRequest(method, target, payload) setEditArticleBodyJwtCookie(t, req, token) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } func TestArticleBodyUnlimitedHttp(t *testing.T) { validator.InitValidator() token := jwtauth.New("HS256", []byte("test-secret"), nil) const title = "Choosing tiles for a small bathroom" createCases := []struct { name string query string payload string format string rendered string }{ {"markdown without body key", "", `{"title": "` + title + `"}`, "markdown", `""`}, {"html without body key", "?format=html", `{"title": "` + title + `"}`, "html", `""`}, {"json without body key", "?format=json", `{"title": "` + title + `"}`, "json", `[]`}, {"markdown with empty body", "", `{"title": "` + title + `", "body": ""}`, "markdown", `""`}, } for _, tc := range createCases { t.Run("Create with only a title succeeds in every body format: "+tc.name, func(t *testing.T) { repo := newMockArticleRepo() router := newUnlimitedBodyRouter(repo, token) rec := serveUnlimitedBody(t, router, token, http.MethodPost, "/v1/article/"+tc.query, tc.payload) if rec.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) } var got struct { Title string `json:"title"` IsOnline bool `json:"is_online"` BodyFormat string `json:"body_format"` Body json.RawMessage `json:"body"` } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got.Title != title || got.IsOnline || got.BodyFormat != tc.format || string(got.Body) != tc.rendered { t.Errorf("unexpected response: %s", rec.Body.String()) } if len(repo.articles) != 1 { t.Fatalf("expected one article, got %d", len(repo.articles)) } for _, stored := range repo.articles { if stored.Body != "" { t.Errorf("expected empty stored body, got %q", stored.Body) } } }) } t.Run("A body of 120,000 characters is accepted on create", func(t *testing.T) { repo := newMockArticleRepo() router := newUnlimitedBodyRouter(repo, token) body := strings.Repeat("a", 120000) rec := serveUnlimitedBody(t, router, token, http.MethodPost, "/v1/article/", `{"title": "`+title+`", "body": "`+body+`"}`) if rec.Code != http.StatusCreated { t.Fatalf("expected 201, got %d", rec.Code) } for _, stored := range repo.articles { if stored.Body != body { t.Errorf("stored body differs, length %d", len(stored.Body)) } } }) for _, length := range []int{10001, 120000} { t.Run("Update the body with no length limit: "+strconv.Itoa(length), func(t *testing.T) { repo := newEditArticleBodyTestRepo() router := newUnlimitedBodyRouter(repo, token) body := strings.Repeat("a", length) rec := serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/1", `{"body": "`+body+`"}`) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if len(repo.articles[1].Body) != length { t.Errorf("expected %d characters, got %d", length, len(repo.articles[1].Body)) } }) } t.Run("Update the body to empty", func(t *testing.T) { repo := newEditArticleBodyTestRepo() router := newUnlimitedBodyRouter(repo, token) rec := serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/1", `{"body": ""}`) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if repo.articles[1].Body != "" { t.Errorf("expected empty body, got %q", repo.articles[1].Body) } get := serveUnlimitedBody(t, router, token, http.MethodGet, "/v1/article/1?format=json", "") var got struct { Body json.RawMessage `json:"body"` } if err := json.Unmarshal(get.Body.Bytes(), &got); err != nil { t.Fatalf("failed to decode response: %v", err) } if string(got.Body) != "[]" { t.Errorf("expected [], got %s", got.Body) } }) titleCases := []struct { name string payload string }{ {"missing title", `{"body": "Hello"}`}, {"title too short", `{"title": "Ti", "body": "Hello"}`}, {"title too long", `{"title": "` + strings.Repeat("a", 201) + `"}`}, } for _, tc := range titleCases { t.Run("Title rules are unchanged: "+tc.name, func(t *testing.T) { repo := newMockArticleRepo() router := newUnlimitedBodyRouter(repo, token) rec := serveUnlimitedBody(t, router, token, http.MethodPost, "/v1/article/", tc.payload) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } if len(repo.articles) != 0 { t.Errorf("expected no article, got %d", len(repo.articles)) } }) } t.Run("Existing guards on PATCH are unchanged", func(t *testing.T) { repo := newEditArticleBodyTestRepo() router := newUnlimitedBodyRouter(repo, token) rec := serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/abc", `{"body": "x"}`) if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" { t.Errorf("unexpected id guard: %d %q", rec.Code, rec.Body.String()) } rec = serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/999", `{"body": "x"}`) if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" { t.Errorf("unexpected not found guard: %d %q", rec.Code, rec.Body.String()) } rec = serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/1", `not json`) if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.BodyErrorMessage+"\n" { t.Errorf("unexpected malformed guard: %d %q", rec.Code, rec.Body.String()) } }) }