package test import ( "RenewCMS/api" "RenewCMS/api/handlers/auth" validator "RenewCMS/api/pkg/validator" domainUser "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" imageDomain "RenewCMS/internal/domain/image" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" ) func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mockUserRepo) { t.Helper() token := jwtauth.New("HS256", []byte("test-secret"), nil) userRepo := newMockUserRepo() hasher := hashing.NewBcryptHasher() hashed, err := hasher.Hash("hunter2example") if err != nil { t.Fatalf("failed to hash password: %v", err) } userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Password: hashed, IsVerified: true} authHandler := auth.NewHandler( token, useCases.NewGetUserUseCase(userRepo), useCases.NewListUsersUseCase(userRepo), useCases.NewCreateUserUseCase(userRepo, hasher), useCases.NewAuthenticateUserUseCase(userRepo, hasher), isProduction, ) r := chi.NewRouter() r.Post("/login", authHandler.Login) return r, userRepo } func performLoginRequest(t *testing.T, router *chi.Mux) *httptest.ResponseRecorder { t.Helper() body, err := json.Marshal(map[string]string{"username": "alice", "password": "hunter2example"}) if err != nil { t.Fatalf("failed to marshal body: %v", err) } req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } func secureAttributeFromResponse(t *testing.T, rec *httptest.ResponseRecorder) bool { t.Helper() values := rec.Result().Header.Values("Set-Cookie") for _, value := range values { 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.Secure } } t.Fatalf("expected a jwt Set-Cookie header, got %v", values) return false } func TestRuntimeSettingsInjectionSecureCookie(t *testing.T) { validator.InitValidator() outline := []struct { injectedEnvironment string processEnvironment string secureExpected bool }{ {"production", "production", true}, {"production", "development", true}, {"development", "production", false}, {"development", "development", false}, } for _, tc := range outline { t.Run("Secure follows the injected environment "+tc.injectedEnvironment+"/"+tc.processEnvironment, func(t *testing.T) { isProduction := tc.injectedEnvironment == "production" router, _ := newSecureCookieAuthRouter(t, isProduction) t.Setenv("ENVIRONMENT", tc.processEnvironment) rec := performLoginRequest(t, router) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String()) } if got := secureAttributeFromResponse(t, rec); got != tc.secureExpected { t.Errorf("expected Secure=%v, got %v", tc.secureExpected, got) } }) } t.Run("Changing the process environment after construction does not change already-built behaviour", func(t *testing.T) { router, _ := newSecureCookieAuthRouter(t, true) t.Setenv("ENVIRONMENT", "development") rec := performLoginRequest(t, router) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String()) } if got := secureAttributeFromResponse(t, rec); !got { t.Errorf("expected Secure=true, got %v", got) } }) } func newRuntimeSettingsContainer(t *testing.T) *api.UseCases { t.Helper() articleRepo := newMockArticleRepo() userRepo := newMockUserRepo() imageRepo := newMockImageRepo() mailRepo := newMockMailRepo() hasher := hashing.NewBcryptHasher() 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), 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), } } func TestRuntimeSettingsInjectionCors(t *testing.T) { validator.InitValidator() container := newRuntimeSettingsContainer(t) t.Run("CORS allows a request from an injected allowed origin", func(t *testing.T) { settings := config.Settings{CorsAllowedOrigins: []string{"https://cms.example.com"}} router := api.InitRoutes(container, settings) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) req.Header.Set("Origin", "https://cms.example.com") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "https://cms.example.com" { t.Errorf("expected Access-Control-Allow-Origin %q, got %q", "https://cms.example.com", got) } }) t.Run("CORS does not allow a request from an origin outside the injected list", func(t *testing.T) { settings := config.Settings{CorsAllowedOrigins: []string{"https://cms.example.com"}} router := api.InitRoutes(container, settings) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) req.Header.Set("Origin", "https://attacker.example.com") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("expected no Access-Control-Allow-Origin header, got %q", got) } }) t.Run("An empty injected origins list disables CORS entirely", func(t *testing.T) { settings := config.Settings{CorsAllowedOrigins: nil} router := api.InitRoutes(container, settings) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) req.Header.Set("Origin", "https://cms.example.com") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if got := rec.Result().Header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("expected no Access-Control-Allow-Origin header, got %q", got) } }) } func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) { db := openInMemoryDb(t) if err := db.AutoMigrate(&models.Image{}); err != nil { t.Fatalf("failed to migrate: %v", err) } injectedUploadDir := t.TempDir() repo := persistence.NewImageRepository(db, injectedUploadDir) processUploadDir := t.TempDir() t.Setenv("UPLOAD_DIR", processUploadDir) result, err := repo.Create(imageDomain.ImageInput{ Content: bytes.NewReader([]byte("fake-image-data")), Filename: "upload.png", ContentType: "image/png", }) if err != nil { t.Fatalf("Create failed: %v", err) } if got := result.Path; len(got) < len("/static/uploadedImages/") || got[:len("/static/uploadedImages/")] != "/static/uploadedImages/" { t.Errorf("expected Path to start with /static/uploadedImages/, got %q", got) } fileName := filepath.Base(result.Path) if _, err := os.Stat(filepath.Join(injectedUploadDir, fileName)); err != nil { t.Errorf("expected the file to exist under the injected upload dir: %v", err) } if _, err := os.Stat(filepath.Join(processUploadDir, fileName)); !os.IsNotExist(err) { t.Errorf("expected the file to not exist under the process UPLOAD_DIR") } } func TestRuntimeSettingsInjectionMailer(t *testing.T) { smtp := config.SmtpSettings{ Email: "notify@example.com", Host: "smtp.example.com", Port: 587, Password: "unused-in-test", } repo := mailer.NewMailRepository(smtp) if repo == nil { t.Fatalf("expected a non-nil MailRepository") } }