mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-06-03 17:32:21 +02:00
7f776fe0fe
* Add Warn and Trace loglevels * Move to slog * fixes * missing test file * Fix tests * Add wrapper with trace * fix * fix lint * LINT * LINT * 🍱 Use only 4 level of logs * fix after merge * 🍱 fix lint * 🍱 fix lint + remove trace logger from tests * 🍱 fix tests * 🍱 fix lint * 🍱 try to fix test * 🍱 Fix tests * 🍱 fix README + adjust logs --------- Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
321 lines
10 KiB
Go
321 lines
10 KiB
Go
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
|
)
|
|
|
|
// getTestConfig returns a minimal valid configuration for testing.
|
|
// Override specific fields by modifying the returned config.
|
|
func getTestConfig() *configuration.Config {
|
|
return &configuration.Config{
|
|
Enabled: true,
|
|
LogLevel: "INFO",
|
|
LogFormat: "common",
|
|
LogFilePath: "",
|
|
CrowdsecMode: "none",
|
|
CrowdsecLapiKey: "test-key",
|
|
CrowdsecLapiHost: "localhost",
|
|
CrowdsecLapiScheme: "http",
|
|
UpdateIntervalSeconds: 60,
|
|
DefaultDecisionSeconds: 60,
|
|
HTTPTimeoutSeconds: 10,
|
|
ForwardedHeadersTrustedIPs: []string{"127.0.0.1"},
|
|
ForwardedHeadersCustomName: "",
|
|
RemediationStatusCode: 403,
|
|
BanHTMLFilePath: "",
|
|
RemediationHeadersCustomName: "",
|
|
CaptchaProvider: "",
|
|
CaptchaSiteKey: "",
|
|
CaptchaSecretKey: "",
|
|
CaptchaGracePeriodSeconds: 1,
|
|
CaptchaHTMLFilePath: "",
|
|
RedisCacheEnabled: false,
|
|
RedisCacheHost: "",
|
|
RedisCachePassword: "",
|
|
RedisCacheDatabase: "",
|
|
RedisCacheUnreachableBlock: false,
|
|
CrowdsecAppsecEnabled: false,
|
|
CrowdsecAppsecHost: "",
|
|
CrowdsecAppsecPath: "",
|
|
CrowdsecAppsecFailureBlock: false,
|
|
CrowdsecAppsecUnreachableBlock: false,
|
|
CrowdsecLapiTLSInsecureVerify: true,
|
|
CrowdsecLapiTLSCertificateBouncer: "",
|
|
CrowdsecLapiTLSCertificateBouncerKey: "",
|
|
CrowdsecCapiMachineID: "",
|
|
CrowdsecCapiPassword: "",
|
|
CrowdsecCapiScenarios: []string{},
|
|
UpdateMaxFailure: 0,
|
|
MetricsUpdateIntervalSeconds: 0,
|
|
}
|
|
}
|
|
|
|
// Helper function to create and execute a bouncer request for testing
|
|
func createAndExecuteBouncerRequest(t *testing.T, config *configuration.Config) {
|
|
t.Helper()
|
|
|
|
// Create a mock next handler
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("OK"))
|
|
})
|
|
|
|
// Create the bouncer plugin (this will initialize the logger with file output)
|
|
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create bouncer: %v", err)
|
|
}
|
|
|
|
// Create a test request to trigger logging
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
|
req.RemoteAddr = "192.168.1.100:12345" // Use a non-trusted IP to trigger logging
|
|
rw := httptest.NewRecorder()
|
|
|
|
// Process the request (this should generate log entries)
|
|
bouncerHandler.ServeHTTP(rw, req)
|
|
|
|
// Give a moment for log writes to complete
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
// Helper function to parse log file and extract found levels
|
|
func parseLogFileAndExtractLevels(t *testing.T, logFile string) map[string]bool {
|
|
t.Helper()
|
|
|
|
// Verify the log file was created and contains entries
|
|
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
|
t.Fatalf("Log file was not created: %s", logFile)
|
|
}
|
|
|
|
// Read the log file content
|
|
// #nosec G304 - logFile is a test-generated temporary file path
|
|
logContent, err := os.ReadFile(logFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read log file: %v", err)
|
|
}
|
|
|
|
logString := string(logContent)
|
|
if len(logString) == 0 {
|
|
return make(map[string]bool) // Return empty map for empty log files
|
|
}
|
|
|
|
// Parse and verify JSON log entries
|
|
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
|
foundLevels := make(map[string]bool)
|
|
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
var logEntry map[string]interface{}
|
|
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
|
t.Errorf("Invalid JSON log entry: %s, error: %v", line, err)
|
|
continue
|
|
}
|
|
|
|
// Verify required fields
|
|
validateLogEntry(t, logEntry)
|
|
|
|
// Track log levels we've seen
|
|
if level, ok := logEntry["level"].(string); ok {
|
|
foundLevels[level] = true
|
|
}
|
|
}
|
|
|
|
return foundLevels
|
|
}
|
|
|
|
// Helper function to validate log entry structure
|
|
func validateLogEntry(t *testing.T, logEntry map[string]interface{}) {
|
|
t.Helper()
|
|
|
|
if logEntry["time"] == nil {
|
|
t.Error("Log entry missing 'time' field")
|
|
}
|
|
if logEntry["level"] == nil {
|
|
t.Error("Log entry missing 'level' field")
|
|
}
|
|
if logEntry["msg"] == nil {
|
|
t.Error("Log entry missing 'msg' field")
|
|
}
|
|
if logEntry["component"] != "CrowdsecBouncerTraefikPlugin" {
|
|
t.Errorf("Expected component 'CrowdsecBouncerTraefikPlugin', got %v", logEntry["component"])
|
|
}
|
|
}
|
|
|
|
// Helper function to verify expected and forbidden log levels
|
|
func verifyLogLevels(t *testing.T, foundLevels map[string]bool, expectedLevels, forbiddenLevels []string, logLevel string) {
|
|
t.Helper()
|
|
|
|
// Handle case where no logs are expected
|
|
if len(expectedLevels) == 0 {
|
|
if len(foundLevels) > 0 {
|
|
t.Errorf("Expected no logs at %s level, but found: %v", logLevel, foundLevels)
|
|
}
|
|
} else {
|
|
// Verify we got some log entries
|
|
if len(foundLevels) == 0 {
|
|
t.Fatal("No valid log entries found")
|
|
}
|
|
|
|
// Verify expected levels are present
|
|
for _, expectedLevel := range expectedLevels {
|
|
if !foundLevels[expectedLevel] {
|
|
t.Errorf("Expected to find %s level logs, but didn't. Found levels: %v", expectedLevel, foundLevels)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify forbidden levels are NOT present
|
|
for _, forbiddenLevel := range forbiddenLevels {
|
|
if foundLevels[forbiddenLevel] {
|
|
t.Errorf("Found forbidden %s level logs at %s level. Found levels: %v", forbiddenLevel, logLevel, foundLevels)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBouncerFileLoggingLevels(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
logLevel string
|
|
expectedLevels []string // Levels that should appear
|
|
forbiddenLevels []string // Levels that should NOT appear
|
|
}{
|
|
{
|
|
name: "DEBUG level should show DEBUG only",
|
|
logLevel: "DEBUG",
|
|
expectedLevels: []string{"DEBUG"},
|
|
forbiddenLevels: []string{},
|
|
},
|
|
{
|
|
name: "INFO level should show no logs (bouncer doesn't generate INFO during normal operation)",
|
|
logLevel: "INFO",
|
|
expectedLevels: []string{}, // No logs expected for normal operation
|
|
forbiddenLevels: []string{"DEBUG"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create temporary directory for log file
|
|
tmpDir := t.TempDir()
|
|
logFile := filepath.Join(tmpDir, "bouncer.log")
|
|
|
|
// Get test config and override specific fields
|
|
config := getTestConfig()
|
|
config.LogLevel = tt.logLevel
|
|
config.LogFormat = "json" // Use JSON format for easier parsing
|
|
config.LogFilePath = logFile
|
|
|
|
// Create and execute bouncer request
|
|
createAndExecuteBouncerRequest(t, config)
|
|
|
|
// Parse log file and extract found levels
|
|
foundLevels := parseLogFileAndExtractLevels(t, logFile)
|
|
|
|
// Handle empty log files for higher log levels (expected behavior)
|
|
if len(foundLevels) == 0 && len(tt.expectedLevels) > 0 {
|
|
t.Fatalf("Expected log entries but log file is empty for level %s", tt.logLevel)
|
|
}
|
|
if len(foundLevels) == 0 {
|
|
// Empty file is expected for this log level
|
|
t.Logf("LogLevel %s: No logs generated (expected behavior)", tt.logLevel)
|
|
return
|
|
}
|
|
|
|
// Verify expected and forbidden log levels
|
|
verifyLogLevels(t, foundLevels, tt.expectedLevels, tt.forbiddenLevels, tt.logLevel)
|
|
|
|
t.Logf("LogLevel %s: Successfully logged to file with levels: %v", tt.logLevel, foundLevels)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBouncerFileLoggingCommonFormat(t *testing.T) {
|
|
// Create temporary directory for log file
|
|
tmpDir := t.TempDir()
|
|
logFile := filepath.Join(tmpDir, "bouncer-common.log")
|
|
|
|
// Get test config and override specific fields
|
|
config := getTestConfig()
|
|
config.LogLevel = "DEBUG"
|
|
config.LogFormat = "common" // Use common format
|
|
config.LogFilePath = logFile
|
|
|
|
// Create a mock next handler
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("OK"))
|
|
})
|
|
|
|
// Create the bouncer plugin
|
|
bouncerHandler, err := New(context.Background(), nextHandler, config, "test-bouncer")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create bouncer: %v", err)
|
|
}
|
|
|
|
// Create a test request to trigger logging
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
|
|
req.RemoteAddr = "192.168.1.100:12345"
|
|
rw := httptest.NewRecorder()
|
|
|
|
// Process the request
|
|
bouncerHandler.ServeHTTP(rw, req)
|
|
|
|
// Give a moment for log writes to complete
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Verify the log file was created and contains entries
|
|
if _, statErr := os.Stat(logFile); os.IsNotExist(statErr) {
|
|
t.Fatalf("Log file was not created: %s", logFile)
|
|
}
|
|
|
|
// Read the log file content
|
|
// #nosec G304 - logFile is a test-generated temporary file path
|
|
logContent, err := os.ReadFile(logFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read log file: %v", err)
|
|
}
|
|
|
|
logString := string(logContent)
|
|
if len(logString) == 0 {
|
|
t.Fatal("Log file is empty")
|
|
}
|
|
|
|
// Verify common format structure
|
|
lines := strings.Split(strings.TrimSpace(logString), "\n")
|
|
foundDebug := false
|
|
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
// Common format should contain time, level, msg, and component
|
|
if strings.Contains(line, "level=DEBUG") {
|
|
foundDebug = true
|
|
}
|
|
if !strings.Contains(line, "component=CrowdsecBouncerTraefikPlugin") {
|
|
t.Errorf("Log line missing component field: %s", line)
|
|
}
|
|
}
|
|
|
|
// We should see DEBUG level logs since we set LogLevel to DEBUG
|
|
if !foundDebug {
|
|
t.Errorf("Expected to find DEBUG level logs in common format. Log content:\n%s", logString)
|
|
}
|
|
|
|
t.Logf("Successfully logged to file %s in common format with %d lines", logFile, len(lines))
|
|
}
|