mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-21 03:28:59 +02:00
🔊 Improve Logging: move to slog and add trace level (#276)
* 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>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user