A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
378 lines
9.7 KiB
Go
378 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|