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>
422 lines
11 KiB
Go
422 lines
11 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func TestInputGate_AllowsGETRequest(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(InputGate())
|
|
|
|
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 for GET request, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_AllowsHEADRequest(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(InputGate())
|
|
|
|
router.HEAD("/test", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodHead, "/test", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200 for HEAD request, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_AllowsOPTIONSRequest(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(InputGate())
|
|
|
|
router.OPTIONS("/test", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/test", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200 for OPTIONS request, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_AllowsValidJSONRequest(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(InputGate())
|
|
|
|
router.POST("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
body := bytes.NewBufferString(`{"key": "value"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Content-Length", "16")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200 for valid JSON, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_RejectsInvalidContentType(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
|
|
config := DefaultInputGateConfig()
|
|
config.StrictContentType = true
|
|
router.Use(InputGateWithConfig(config))
|
|
|
|
router.POST("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
body := bytes.NewBufferString(`data`)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
|
req.Header.Set("Content-Type", "application/xml")
|
|
req.Header.Set("Content-Length", "4")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnsupportedMediaType {
|
|
t.Errorf("Expected status 415 for invalid content type, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_AllowsEmptyContentType(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(InputGate())
|
|
|
|
router.POST("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
body := bytes.NewBufferString(`data`)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
|
// No Content-Type header
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200 for empty content type, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_RejectsOversizedRequest(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
|
|
config := DefaultInputGateConfig()
|
|
config.MaxBodySize = 100 // 100 bytes
|
|
router.Use(InputGateWithConfig(config))
|
|
|
|
router.POST("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
// Create a body larger than 100 bytes
|
|
largeBody := strings.Repeat("x", 200)
|
|
body := bytes.NewBufferString(largeBody)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Content-Length", "200")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("Expected status 413 for oversized request, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_AllowsLargeUploadPath(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
|
|
config := DefaultInputGateConfig()
|
|
config.MaxBodySize = 100 // 100 bytes
|
|
config.MaxFileSize = 1000 // 1000 bytes
|
|
config.LargeUploadPaths = []string{"/api/v1/files/upload"}
|
|
router.Use(InputGateWithConfig(config))
|
|
|
|
router.POST("/api/v1/files/upload", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
// Create a body larger than MaxBodySize but smaller than MaxFileSize
|
|
largeBody := strings.Repeat("x", 500)
|
|
body := bytes.NewBufferString(largeBody)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/files/upload", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Content-Length", "500")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200 for large upload path, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_ExcludedPaths(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
|
|
config := DefaultInputGateConfig()
|
|
config.MaxBodySize = 10 // Very small
|
|
config.ExcludedPaths = []string{"/health"}
|
|
router.Use(InputGateWithConfig(config))
|
|
|
|
router.POST("/health", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "healthy"})
|
|
})
|
|
|
|
// Send oversized body to excluded path
|
|
largeBody := strings.Repeat("x", 100)
|
|
body := bytes.NewBufferString(largeBody)
|
|
req := httptest.NewRequest(http.MethodPost, "/health", body)
|
|
req.Header.Set("Content-Length", "100")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Should pass because path is excluded
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200 for excluded path, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInputGate_RejectsInvalidContentLength(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(InputGate())
|
|
|
|
router.POST("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
body := bytes.NewBufferString(`data`)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Content-Length", "invalid")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for invalid content length, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateFileUpload_BlockedExtension(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
contentType string
|
|
blocked bool
|
|
}{
|
|
{"malware.exe", "application/octet-stream", true},
|
|
{"script.bat", "application/octet-stream", true},
|
|
{"hack.cmd", "application/octet-stream", true},
|
|
{"shell.sh", "application/octet-stream", true},
|
|
{"powershell.ps1", "application/octet-stream", true},
|
|
{"document.pdf", "application/pdf", false},
|
|
{"image.jpg", "image/jpeg", false},
|
|
{"data.csv", "text/csv", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
valid, errMsg := ValidateFileUpload(tt.filename, tt.contentType, 100, nil)
|
|
if tt.blocked && valid {
|
|
t.Errorf("File %s should be blocked", tt.filename)
|
|
}
|
|
if !tt.blocked && !valid {
|
|
t.Errorf("File %s should not be blocked, error: %s", tt.filename, errMsg)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateFileUpload_OversizedFile(t *testing.T) {
|
|
config := DefaultInputGateConfig()
|
|
config.MaxFileSize = 1000 // 1KB
|
|
|
|
valid, errMsg := ValidateFileUpload("test.pdf", "application/pdf", 2000, &config)
|
|
|
|
if valid {
|
|
t.Error("Should reject oversized file")
|
|
}
|
|
if !strings.Contains(errMsg, "size") {
|
|
t.Errorf("Error message should mention size, got: %s", errMsg)
|
|
}
|
|
}
|
|
|
|
func TestValidateFileUpload_ValidFile(t *testing.T) {
|
|
config := DefaultInputGateConfig()
|
|
|
|
valid, errMsg := ValidateFileUpload("document.pdf", "application/pdf", 1000, &config)
|
|
|
|
if !valid {
|
|
t.Errorf("Should accept valid file, got error: %s", errMsg)
|
|
}
|
|
}
|
|
|
|
func TestValidateFileUpload_InvalidContentType(t *testing.T) {
|
|
config := DefaultInputGateConfig()
|
|
|
|
valid, errMsg := ValidateFileUpload("file.xyz", "application/x-unknown", 100, &config)
|
|
|
|
if valid {
|
|
t.Error("Should reject unknown file type")
|
|
}
|
|
if !strings.Contains(errMsg, "not allowed") {
|
|
t.Errorf("Error message should mention not allowed, got: %s", errMsg)
|
|
}
|
|
}
|
|
|
|
func TestValidateFileUpload_NilConfig(t *testing.T) {
|
|
// Should use default config when nil is passed
|
|
valid, _ := ValidateFileUpload("document.pdf", "application/pdf", 1000, nil)
|
|
|
|
if !valid {
|
|
t.Error("Should accept valid file with nil config (uses defaults)")
|
|
}
|
|
}
|
|
|
|
func TestHasBlockedExtension(t *testing.T) {
|
|
config := DefaultInputGateConfig()
|
|
|
|
tests := []struct {
|
|
filename string
|
|
blocked bool
|
|
}{
|
|
{"test.exe", true},
|
|
{"TEST.EXE", true}, // Case insensitive
|
|
{"script.BAT", true},
|
|
{"app.APP", true},
|
|
{"document.pdf", false},
|
|
{"image.png", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := config.hasBlockedExtension(tt.filename)
|
|
if result != tt.blocked {
|
|
t.Errorf("File %s: expected blocked=%v, got %v", tt.filename, tt.blocked, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateContentType(t *testing.T) {
|
|
config := DefaultInputGateConfig()
|
|
|
|
tests := []struct {
|
|
contentType string
|
|
valid bool
|
|
}{
|
|
{"application/json", true},
|
|
{"application/json; charset=utf-8", true},
|
|
{"APPLICATION/JSON", true}, // Case insensitive
|
|
{"multipart/form-data; boundary=----WebKitFormBoundary", true},
|
|
{"text/plain", true},
|
|
{"application/xml", false},
|
|
{"text/html", false},
|
|
{"", true}, // Empty is allowed
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
valid, _ := config.validateContentType(tt.contentType)
|
|
if valid != tt.valid {
|
|
t.Errorf("Content-Type %q: expected valid=%v, got %v", tt.contentType, tt.valid, valid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsLargeUploadPath(t *testing.T) {
|
|
config := DefaultInputGateConfig()
|
|
config.LargeUploadPaths = []string{"/api/v1/files/upload", "/api/v1/documents"}
|
|
|
|
tests := []struct {
|
|
path string
|
|
isLarge bool
|
|
}{
|
|
{"/api/v1/files/upload", true},
|
|
{"/api/v1/files/upload/batch", true}, // Prefix match
|
|
{"/api/v1/documents", true},
|
|
{"/api/v1/documents/1/attachments", true},
|
|
{"/api/v1/users", false},
|
|
{"/health", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := config.isLargeUploadPath(tt.path)
|
|
if result != tt.isLarge {
|
|
t.Errorf("Path %s: expected isLarge=%v, got %v", tt.path, tt.isLarge, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetMaxSize(t *testing.T) {
|
|
config := DefaultInputGateConfig()
|
|
config.MaxBodySize = 100
|
|
config.MaxFileSize = 1000
|
|
config.LargeUploadPaths = []string{"/api/v1/files/upload"}
|
|
|
|
tests := []struct {
|
|
path string
|
|
expected int64
|
|
}{
|
|
{"/api/test", 100},
|
|
{"/api/v1/files/upload", 1000},
|
|
{"/health", 100},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := config.getMaxSize(tt.path)
|
|
if result != tt.expected {
|
|
t.Errorf("Path %s: expected maxSize=%d, got %d", tt.path, tt.expected, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInputGate_DefaultMiddleware(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(InputGate())
|
|
|
|
router.POST("/test", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
body := bytes.NewBufferString(`{"key": "value"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", w.Code)
|
|
}
|
|
}
|