package test import ( "RenewCMS/api/handlers/article" "RenewCMS/api/handlers/auth" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" domainArticle "RenewCMS/internal/domain/article" domainUser "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "github.com/glebarez/sqlite" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" "gorm.io/gorm" ) func openInMemoryDb(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("failed to open in-memory database: %v", err) } return db } func TestArticleRepositoryReportsMissingArticleAsDomainError(t *testing.T) { db := openInMemoryDb(t) if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil { t.Fatalf("failed to migrate: %v", err) } repo := persistence.NewArticleRepository(db) if _, err := repo.Create(domainArticle.Article{Title: "Backyard Renovation Update", Body: "Body"}); err != nil { t.Fatalf("failed to seed article: %v", err) } t.Run("Get id 9", func(t *testing.T) { _, err := repo.Get(9) if !errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } if errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("expected the domain error, not gorm.ErrRecordNotFound") } }) t.Run("GetByName Unknown Title", func(t *testing.T) { _, err := repo.GetByName("Unknown Title") if !errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } if errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("expected the domain error, not gorm.ErrRecordNotFound") } }) t.Run("Get propagates a non-not-found database error unchanged", func(t *testing.T) { sqlDb, err := db.DB() if err != nil { t.Fatalf("failed to get sql db: %v", err) } if err := sqlDb.Close(); err != nil { t.Fatalf("failed to close db: %v", err) } _, err = repo.Get(1) if err == nil { t.Fatal("expected an error, got nil") } if errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("expected a non-not-found error, got domain.ErrArticleNotFound") } }) } func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) { db := openInMemoryDb(t) if err := db.AutoMigrate(&models.User{}); err != nil { t.Fatalf("failed to migrate: %v", err) } repo := persistence.NewUserRepository(db) if _, err := repo.Create(domainUser.User{Username: "alice", Password: "hunter2example", Email: "alice@example.com", VerificationCode: "code"}); err != nil { t.Fatalf("failed to seed user: %v", err) } t.Run("Get id 9", func(t *testing.T) { _, err := repo.Get(9) if !errors.Is(err, domainUser.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } if errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("expected the domain error, not gorm.ErrRecordNotFound") } }) t.Run("GetByUsername bob", func(t *testing.T) { _, err := repo.GetByUsername("bob") if !errors.Is(err, domainUser.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } if errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("expected the domain error, not gorm.ErrRecordNotFound") } }) t.Run("GetByEmail bob@example.com", func(t *testing.T) { _, err := repo.GetByEmail("bob@example.com") if !errors.Is(err, domainUser.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } if errors.Is(err, gorm.ErrRecordNotFound) { t.Errorf("expected the domain error, not gorm.ErrRecordNotFound") } }) } func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { 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()), getUserUseCase, ) hasher := hashing.NewBcryptHasher() loginHandler := auth.NewLoginHandler( token, useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true, ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(jwtauth.Verifier(token)) r.Get("/{id}", articleHandler.GetArticle) }) r.Route("/auth", func(r chi.Router) { r.Post("/login", loginHandler.Login) }) }) return r } func setJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth, userId uint32) { t.Helper() _, tokenString, err := token.Encode(map[string]any{"user_id": userId}) if err != nil { t.Fatalf("failed to encode jwt: %v", err) } req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) } func TestNotFoundErrorsHttp(t *testing.T) { validator.InitValidator() token := jwtauth.New("HS256", []byte("test-secret"), nil) newArticleRepo := 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: "Offline Article", IsOnline: false} return repo } t.Run("Fetching a nonexistent article answers 404, not 500", func(t *testing.T) { router := newNotFoundErrorsRouter(newArticleRepo(), newVerifiedUserRepo(), token) req := httptest.NewRequest(http.MethodGet, "/v1/article/9999", nil) setJwtCookie(t, req, token, 1) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected status 404, got %d", rec.Code) } if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } }) t.Run("Fetching an offline article still answers 404 with the same message", func(t *testing.T) { router := newNotFoundErrorsRouter(newArticleRepo(), newVerifiedUserRepo(), token) req := httptest.NewRequest(http.MethodGet, "/v1/article/2", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected status 404, got %d", rec.Code) } if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } }) t.Run("Fetching an online article still answers 200", func(t *testing.T) { router := newNotFoundErrorsRouter(newArticleRepo(), newVerifiedUserRepo(), token) req := httptest.NewRequest(http.MethodGet, "/v1/article/1", 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(rec.Body.Bytes(), &got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got.ID != 1 { t.Errorf("expected article id 1, got %d", got.ID) } }) t.Run("An unknown article id answers 404 even without authentication", func(t *testing.T) { router := newNotFoundErrorsRouter(newArticleRepo(), newMockUserRepo(), token) req := httptest.NewRequest(http.MethodGet, "/v1/article/9999", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected status 404, got %d", rec.Code) } if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } }) newAliceUserRepo := func() *mockUserRepo { repo := newMockUserRepo() hashed, err := hashing.NewBcryptHasher().Hash("hunter2example") if err != nil { t.Fatalf("failed to hash password: %v", err) } repo.users[1] = domainUser.User{ID: 1, Username: "alice", Password: hashed} return repo } loginCases := []struct { name string username string password string }{ {"wrong password for existing user", "alice", "wrongpassword12"}, {"nonexistent username", "bob", "hunter2example"}, } for _, tc := range loginCases { t.Run("Login never reveals whether the username exists: "+tc.name, func(t *testing.T) { router := newNotFoundErrorsRouter(newArticleRepo(), newAliceUserRepo(), token) body, err := json.Marshal(map[string]string{"username": tc.username, "password": tc.password}) if err != nil { t.Fatalf("failed to marshal body: %v", err) } req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected status 403, got %d", rec.Code) } if rec.Body.String() != apperr.LogsErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } if rec.Header().Get("Set-Cookie") != "" { t.Errorf("expected no Set-Cookie header, got %q", rec.Header().Get("Set-Cookie")) } }) } }