package test import ( "RenewCMS/api/handlers/article" "RenewCMS/api/middleware" domainArticle "RenewCMS/internal/domain/article" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" "errors" "log" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" validator "RenewCMS/api/pkg/validator" ) type erroringResponseWriter struct { header http.Header } func (w *erroringResponseWriter) Header() http.Header { if w.header == nil { w.header = http.Header{} } return w.header } func (w *erroringResponseWriter) Write([]byte) (int, error) { return 0, errors.New("simulated write failure") } 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, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), getUserUseCase, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(jwtauth.Verifier(token)) r.Group(func(r chi.Router) { r.Get("/", articleHandler.ListArticles) r.Get("/{id}", articleHandler.GetArticle) }) r.Group(func(r chi.Router) { r.Use(jwtauth.Authenticator(token)) r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Post("/", articleHandler.PostArticle) r.Delete("/{id}", articleHandler.DeleteArticle) }) }) }) return r } func seedPublicArticleListingRepo() *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 } func TestPublicArticleListingHttp(t *testing.T) { validator.InitValidator() token := jwtauth.New("HS256", []byte("test-secret"), nil) t.Run("An anonymous visitor only sees online articles", func(t *testing.T) { router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token) req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } var got []domainArticle.Article if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(got) != 1 { t.Fatalf("expected exactly one article, got %d", len(got)) } if got[0].ID != 1 { t.Errorf("expected article id 1, got %d", got[0].ID) } }) t.Run("An authenticated user sees every article, online and offline", func(t *testing.T) { router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token) req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) setJwtCookie(t, req, token, 1) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } var got []domainArticle.Article if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(got) != 2 { t.Fatalf("expected exactly two articles, got %d", len(got)) } 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("A malformed or expired jwt cookie falls back to the public online-only list", func(t *testing.T) { router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token) req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) req.AddCookie(&http.Cookie{Name: "jwt", Value: "not-a-real-token"}) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } var got []domainArticle.Article if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(got) != 1 { t.Fatalf("expected exactly one article, got %d", len(got)) } if got[0].ID != 1 { t.Errorf("expected article id 1, got %d", got[0].ID) } }) restRouteCases := []struct { name string method string path string body []byte }{ {"POST /v1/article/ with no jwt cookie answers 401", http.MethodPost, "/v1/article/", []byte(`{"title":"Valid Title","body":"Some content"}`)}, {"DELETE /v1/article/1 with no jwt cookie answers 401", http.MethodDelete, "/v1/article/1", nil}, } for _, tc := range restRouteCases { t.Run(tc.name, func(t *testing.T) { router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token) var req *http.Request if tc.body != nil { req = httptest.NewRequest(tc.method, tc.path, bytes.NewReader(tc.body)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(tc.method, tc.path, nil) } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected status 401, got %d", rec.Code) } }) } t.Run("A response write failure is logged instead of crashing the handler", func(t *testing.T) { var logOutput strings.Builder defaultLogOutput := log.Writer() log.SetOutput(&logOutput) defer log.SetOutput(defaultLogOutput) router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token) req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) router.ServeHTTP(&erroringResponseWriter{}, req) if !strings.Contains(logOutput.String(), "Failed to write response") { t.Errorf("expected the write failure to be logged, got %q", logOutput.String()) } }) t.Run("A repository failure still answers 500 regardless of auth state", func(t *testing.T) { repo := seedPublicArticleListingRepo() repo.fail.failNext("FindPage") router := newPublicArticleListingRouter(repo, token) req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected status 500, got %d", rec.Code) } if rec.Body.String() != "Failed to retrieve articles\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } }) }