package middleware import ( "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" ) func TestSecurityHeaders_AddsBasicHeaders(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.DevelopmentMode = true // Skip HSTS and cross-origin headers router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } // Check basic security headers tests := []struct { header string expected string }{ {"X-Content-Type-Options", "nosniff"}, {"X-Frame-Options", "DENY"}, {"X-XSS-Protection", "1; mode=block"}, {"Referrer-Policy", "strict-origin-when-cross-origin"}, } for _, tt := range tests { value := w.Header().Get(tt.header) if value != tt.expected { t.Errorf("Header %s: expected %q, got %q", tt.header, tt.expected, value) } } } func TestSecurityHeaders_HSTSNotAddedInDevelopment(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.DevelopmentMode = true config.HSTSEnabled = true router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) hstsHeader := w.Header().Get("Strict-Transport-Security") if hstsHeader != "" { t.Errorf("HSTS should not be set in development mode, got: %s", hstsHeader) } } func TestSecurityHeaders_HSTSAddedInProduction(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.DevelopmentMode = false config.HSTSEnabled = true config.HSTSMaxAge = 31536000 config.HSTSIncludeSubdomains = true router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) hstsHeader := w.Header().Get("Strict-Transport-Security") if hstsHeader == "" { t.Error("HSTS should be set in production mode") } // Check that it contains max-age if hstsHeader != "max-age=31536000; includeSubDomains" { t.Errorf("Unexpected HSTS value: %s", hstsHeader) } } func TestSecurityHeaders_HSTSWithPreload(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.DevelopmentMode = false config.HSTSEnabled = true config.HSTSMaxAge = 31536000 config.HSTSIncludeSubdomains = true config.HSTSPreload = true router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) hstsHeader := w.Header().Get("Strict-Transport-Security") expected := "max-age=31536000; includeSubDomains; preload" if hstsHeader != expected { t.Errorf("Expected HSTS %q, got %q", expected, hstsHeader) } } func TestSecurityHeaders_CSPHeader(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.CSPEnabled = true config.CSPPolicy = "default-src 'self'" router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) cspHeader := w.Header().Get("Content-Security-Policy") if cspHeader != "default-src 'self'" { t.Errorf("Expected CSP %q, got %q", "default-src 'self'", cspHeader) } } func TestSecurityHeaders_NoCSPWhenDisabled(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.CSPEnabled = false router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) cspHeader := w.Header().Get("Content-Security-Policy") if cspHeader != "" { t.Errorf("CSP should not be set when disabled, got: %s", cspHeader) } } func TestSecurityHeaders_ExcludedPaths(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.ExcludedPaths = []string{"/health", "/metrics"} router.Use(SecurityHeadersWithConfig(config)) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "healthy"}) }) router.GET("/api", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) // Test excluded path req := httptest.NewRequest(http.MethodGet, "/health", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Header().Get("X-Content-Type-Options") != "" { t.Error("Security headers should not be set for excluded paths") } // Test non-excluded path req = httptest.NewRequest(http.MethodGet, "/api", nil) w = httptest.NewRecorder() router.ServeHTTP(w, req) if w.Header().Get("X-Content-Type-Options") != "nosniff" { t.Error("Security headers should be set for non-excluded paths") } } func TestSecurityHeaders_CrossOriginInProduction(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.DevelopmentMode = false config.CrossOriginOpenerPolicy = "same-origin" config.CrossOriginResourcePolicy = "same-origin" router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) coopHeader := w.Header().Get("Cross-Origin-Opener-Policy") if coopHeader != "same-origin" { t.Errorf("Expected COOP %q, got %q", "same-origin", coopHeader) } corpHeader := w.Header().Get("Cross-Origin-Resource-Policy") if corpHeader != "same-origin" { t.Errorf("Expected CORP %q, got %q", "same-origin", corpHeader) } } func TestSecurityHeaders_NoCrossOriginInDevelopment(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.DevelopmentMode = true config.CrossOriginOpenerPolicy = "same-origin" config.CrossOriginResourcePolicy = "same-origin" router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Header().Get("Cross-Origin-Opener-Policy") != "" { t.Error("COOP should not be set in development mode") } if w.Header().Get("Cross-Origin-Resource-Policy") != "" { t.Error("CORP should not be set in development mode") } } func TestSecurityHeaders_PermissionsPolicy(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() config := DefaultSecurityHeadersConfig() config.PermissionsPolicy = "geolocation=(), microphone=()" router.Use(SecurityHeadersWithConfig(config)) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) ppHeader := w.Header().Get("Permissions-Policy") if ppHeader != "geolocation=(), microphone=()" { t.Errorf("Expected Permissions-Policy %q, got %q", "geolocation=(), microphone=()", ppHeader) } } func TestSecurityHeaders_DefaultMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() // Use the default middleware function router.Use(SecurityHeaders()) router.GET("/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) // Should at least have the basic headers if w.Header().Get("X-Content-Type-Options") != "nosniff" { t.Error("Default middleware should set X-Content-Type-Options") } } func TestBuildHSTSHeader(t *testing.T) { tests := []struct { name string config SecurityHeadersConfig expected string }{ { name: "basic HSTS", config: SecurityHeadersConfig{ HSTSMaxAge: 31536000, HSTSIncludeSubdomains: false, HSTSPreload: false, }, expected: "max-age=31536000", }, { name: "HSTS with subdomains", config: SecurityHeadersConfig{ HSTSMaxAge: 31536000, HSTSIncludeSubdomains: true, HSTSPreload: false, }, expected: "max-age=31536000; includeSubDomains", }, { name: "HSTS with preload", config: SecurityHeadersConfig{ HSTSMaxAge: 31536000, HSTSIncludeSubdomains: true, HSTSPreload: true, }, expected: "max-age=31536000; includeSubDomains; preload", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.config.buildHSTSHeader() if result != tt.expected { t.Errorf("Expected %q, got %q", tt.expected, result) } }) } } func TestIsExcludedPath(t *testing.T) { config := SecurityHeadersConfig{ ExcludedPaths: []string{"/health", "/metrics", "/api/v1/health"}, } tests := []struct { path string excluded bool }{ {"/health", true}, {"/metrics", true}, {"/api/v1/health", true}, {"/api", false}, {"/health/check", false}, {"/", false}, } for _, tt := range tests { result := config.isExcludedPath(tt.path) if result != tt.excluded { t.Errorf("Path %s: expected excluded=%v, got %v", tt.path, tt.excluded, result) } } }