mirror of
https://github.com/ivuorinen/f2b.git
synced 2026-01-26 11:24:00 +00:00
* refactor: consolidate test helpers and reduce code duplication
- Fix prealloc lint issue in cmd_logswatch_test.go
- Add validateIPAndJails helper to consolidate IP/jail validation
- Add WithTestRunner/WithTestSudoChecker helpers for cleaner test setup
- Replace setupBasicMockResponses duplicates with StandardMockSetup
- Add SetupStandardResponses/SetupJailResponses to MockRunner
- Delegate cmd context helpers to fail2ban implementations
- Document context wrapper pattern in context_helpers.go
* refactor: consolidate duplicate code patterns across cmd and fail2ban packages
Add helper functions to reduce code duplication found by dupl:
- safeCloseFile/safeCloseReader: centralize file close error logging
- createTimeoutContext: consolidate timeout context creation pattern
- withContextCheck: wrap context cancellation checks
- recordOperationMetrics: unify metrics recording for commands/clients
Also includes Phase 1 consolidations:
- copyBuckets helper for metrics snapshots
- Table-driven context extraction in logging
- processWithValidation helper for IP processors
* refactor: consolidate LoggerInterface by embedding LoggerEntry
Both interfaces had identical method signatures. LoggerInterface now
embeds LoggerEntry to eliminate code duplication.
* refactor: consolidate test framework helpers and fix test patterns
- Add checkJSONFieldValue and failMissingJSONField helpers to reduce
duplication in JSON assertion methods
- Add ParallelTimeout to default test config
- Fix test to use WithTestRunner inside test loop for proper mock scoping
* refactor: unify ban/unban operations with OperationType pattern
Introduce OperationType struct to consolidate duplicate ban/unban logic:
- Add ProcessOperation and ProcessOperationWithContext generic functions
- Add ProcessOperationParallel and ProcessOperationParallelWithContext
- Existing ProcessBan*/ProcessUnban* functions now delegate to generic versions
- Reduces ~120 lines of duplicate code between ban and unban operations
* refactor: consolidate time parsing cache pattern
Add ParseWithLayout method to BoundedTimeCache that consolidates the
cache-lookup-parse-store pattern. FastTimeCache and TimeParsingCache
now delegate to this method instead of duplicating the logic.
* refactor: consolidate command execution patterns in fail2ban
- Add validateCommandExecution helper for command/argument validation
- Add runWithTimerContext helper for timed runner operations
- Add executeIPActionWithContext to unify BanIP/UnbanIP implementations
- Reduces duplicate validation and execution boilerplate
* refactor: consolidate logrus adapter with embedded loggerCore
Introduce loggerCore type that provides the 8 standard logging methods
(Debug, Info, Warn, Error, Debugf, Infof, Warnf, Errorf). Both
logrusAdapter and logrusEntryAdapter now embed this type, eliminating
16 duplicate method implementations.
* refactor: consolidate path validation patterns
- Add validateConfigPathWithFallback helper in cmd/config_utils.go
for the validate-or-fallback-with-logging pattern
- Add validateClientPath helper in fail2ban/helpers.go for client
path validation delegation
* fix: add context cancellation checks to wrapper functions
- wrapWithContext0/1/2 now check ctx.Err() before invoking wrapped function
- WithCommand now validates and trims empty command strings
* refactor: extract formatLatencyBuckets for deterministic metrics output
Add formatLatencyBuckets helper that writes latency bucket distribution
with sorted keys for deterministic output, eliminating duplicate
formatting code for command and client latency buckets.
* refactor: add generic setNestedMapValue helper for mock configuration
Add setNestedMapValue[T] generic helper that consolidates the repeated
pattern of mutex-protected nested map initialization and value setting
used by SetBanError, SetBanResult, SetUnbanError, and SetUnbanResult.
* fix: use cmd.Context() for signal propagation and correct mock status
- ExecuteIPCommand now uses cmd.Context() instead of context.Background()
to inherit Cobra's signal cancellation
- MockRunner.SetupJailResponses uses shared.Fail2BanStatusSuccess ("0")
instead of literal "1" for proper success path simulation
* fix: restore operation-specific log messages in ProcessOperationWithContext
Add back Logger.WithFields().Info(opType.Message) call that was lost
during refactoring. This restores the distinction between ban and unban
operation messages (shared.MsgBanResult vs shared.MsgUnbanResult).
* fix: return aggregated errors from parallel operations
Previously, errors from individual parallel operations were silently
swallowed - converted to status strings but never returned to callers.
Now processOperations collects all errors and returns them aggregated
via errors.Join, allowing callers to distinguish partial failures from
complete success while still receiving all results.
* fix: add input validation to processOperations before parallel execution
Validate IP and jail inputs at the start of processOperations() using
fail2ban.CachedValidateIP and CachedValidateJail. This prevents invalid
or malicious inputs (empty values, path traversal attempts, malformed
IPs) from reaching the operation functions. All validation errors are
aggregated and returned before any operations execute.
352 lines
12 KiB
Go
352 lines
12 KiB
Go
package fail2ban
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/ivuorinen/f2b/shared"
|
|
)
|
|
|
|
// TestingInterface represents the common interface between testing.T and testing.B
|
|
type TestingInterface interface {
|
|
Helper()
|
|
Fatalf(format string, args ...interface{})
|
|
Skipf(format string, args ...interface{})
|
|
TempDir() string
|
|
}
|
|
|
|
// setupTestLogEnvironment creates a temp directory, copies test data, and sets up log directory
|
|
// Returns a cleanup function that should be deferred
|
|
func setupTestLogEnvironment(t *testing.T, testDataFile string) (cleanup func()) {
|
|
t.Helper()
|
|
// Validate test data file exists and is safe to read
|
|
absTestLogFile, err := filepath.Abs(testDataFile)
|
|
if err != nil {
|
|
t.Fatalf(shared.ErrFailedToGetAbsPath, err)
|
|
}
|
|
if _, err := os.Stat(absTestLogFile); os.IsNotExist(err) {
|
|
t.Skipf(shared.ErrTestDataNotFound, absTestLogFile)
|
|
}
|
|
|
|
// Ensure the file is within testdata directory for security
|
|
if !strings.Contains(absTestLogFile, shared.TestDataDir) {
|
|
t.Fatalf("Test file must be in testdata directory: %s", absTestLogFile)
|
|
}
|
|
|
|
// Create temp directory and copy test file
|
|
tempDir := t.TempDir()
|
|
mainLog := filepath.Join(tempDir, "fail2ban.log")
|
|
|
|
// #nosec G304 - This is test code reading controlled test data files
|
|
data, err := os.ReadFile(absTestLogFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read test file: %v", err)
|
|
}
|
|
if err := os.WriteFile(mainLog, data, shared.DefaultFilePermissions); err != nil {
|
|
t.Fatalf("Failed to create test log: %v", err)
|
|
}
|
|
|
|
// Set up test environment
|
|
origLogDir := GetLogDir()
|
|
SetLogDir(tempDir)
|
|
|
|
return func() {
|
|
SetLogDir(origLogDir)
|
|
}
|
|
}
|
|
|
|
// SetupMockEnvironment sets up complete mock environment with client, runner, and sudo checker
|
|
func SetupMockEnvironment(t TestingInterface) (client *MockClient, cleanup func()) {
|
|
t.Helper()
|
|
|
|
// Store original components
|
|
originalChecker := GetSudoChecker()
|
|
originalRunner := GetRunner()
|
|
|
|
// Set up mocks
|
|
mockClient := NewMockClient()
|
|
mockChecker := &MockSudoChecker{
|
|
MockHasPrivileges: true,
|
|
ExplicitPrivilegesSet: true,
|
|
}
|
|
mockRunner := NewMockRunner()
|
|
|
|
SetSudoChecker(mockChecker)
|
|
SetRunner(mockRunner)
|
|
|
|
// Configure comprehensive mock responses
|
|
mockRunner.SetResponse(shared.MockCommandVersion, []byte(shared.VersionOutput))
|
|
mockRunner.SetResponse(shared.MockCommandStatus, []byte(shared.StatusOutput))
|
|
mockRunner.SetResponse(shared.MockCommandPing, []byte(shared.PingOutput))
|
|
|
|
// Standard jail responses
|
|
mockRunner.SetResponse(shared.MockCommandStatusSSHD, []byte("Status for the jail: sshd"))
|
|
mockRunner.SetResponse(shared.MockCommandStatusApache, []byte("Status for the jail: apache"))
|
|
|
|
// Standard ban responses
|
|
mockRunner.SetResponse(shared.MockCommandBanIP, []byte(shared.Fail2BanStatusSuccess))
|
|
mockRunner.SetResponse(shared.MockCommandUnbanIP, []byte(shared.Fail2BanStatusSuccess))
|
|
mockRunner.SetResponse(shared.MockCommandBanned, []byte(shared.MockBannedOutput))
|
|
|
|
cleanup = func() {
|
|
SetSudoChecker(originalChecker)
|
|
SetRunner(originalRunner)
|
|
}
|
|
|
|
return mockClient, cleanup
|
|
}
|
|
|
|
// SetupMockEnvironmentWithSudo sets up mock environment with specific sudo privileges
|
|
func SetupMockEnvironmentWithSudo(t TestingInterface, hasSudo bool) (client *MockClient, cleanup func()) {
|
|
t.Helper()
|
|
|
|
// Store original components
|
|
originalChecker := GetSudoChecker()
|
|
originalRunner := GetRunner()
|
|
|
|
// Set up mocks
|
|
mockClient := NewMockClient()
|
|
mockChecker := &MockSudoChecker{
|
|
MockHasPrivileges: hasSudo,
|
|
ExplicitPrivilegesSet: true,
|
|
}
|
|
mockRunner := NewMockRunner()
|
|
|
|
SetSudoChecker(mockChecker)
|
|
SetRunner(mockRunner)
|
|
|
|
// Configure mock responses based on sudo availability
|
|
if hasSudo {
|
|
mockRunner.SetResponse(shared.MockCommandVersion, []byte(shared.VersionOutput))
|
|
mockRunner.SetResponse(shared.MockCommandPing, []byte(shared.PingOutput))
|
|
mockRunner.SetResponse(shared.MockCommandStatus, []byte(shared.StatusOutput))
|
|
}
|
|
|
|
cleanup = func() {
|
|
SetSudoChecker(originalChecker)
|
|
SetRunner(originalRunner)
|
|
}
|
|
|
|
return mockClient, cleanup
|
|
}
|
|
|
|
// SetupBasicMockClient creates a mock client with standard responses configured
|
|
func SetupBasicMockClient() *MockClient {
|
|
client := NewMockClient()
|
|
// Set up common test data
|
|
client.StatusAllData = "Status: [sshd, apache] Jail list: sshd, apache"
|
|
client.StatusJailData["sshd"] = "Status for jail: sshd"
|
|
client.StatusJailData["apache"] = "Status for jail: apache"
|
|
return client
|
|
}
|
|
|
|
// AssertError provides standardized error checking for tests
|
|
func AssertError(t TestingInterface, err error, expectError bool, testName string) {
|
|
t.Helper()
|
|
if expectError && err == nil {
|
|
t.Fatalf(shared.ErrTestExpectedError, testName)
|
|
}
|
|
if !expectError && err != nil {
|
|
t.Fatalf(shared.ErrTestUnexpected, testName, err)
|
|
}
|
|
}
|
|
|
|
// AssertErrorContains checks that error contains expected substring
|
|
func AssertErrorContains(t TestingInterface, err error, expectedSubstring string, testName string) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatalf("%s: expected error containing %q but got none", testName, expectedSubstring)
|
|
}
|
|
if !strings.Contains(err.Error(), expectedSubstring) {
|
|
t.Fatalf("%s: expected error containing %q but got %q", testName, expectedSubstring, err.Error())
|
|
}
|
|
}
|
|
|
|
// AssertCommandSuccess checks that command succeeded and output contains expected text
|
|
func AssertCommandSuccess(t TestingInterface, err error, output, expectedOutput, testName string) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf(shared.ErrTestUnexpectedWithOutput, testName, err, output)
|
|
}
|
|
if expectedOutput != "" && !strings.Contains(output, expectedOutput) {
|
|
t.Fatalf(shared.ErrTestExpectedOutput, testName, expectedOutput, output)
|
|
}
|
|
}
|
|
|
|
// AssertCommandError checks that command failed and output contains expected error text
|
|
func AssertCommandError(t TestingInterface, err error, output, expectedError, testName string) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatalf("%s: expected error but got none, output: %s", testName, output)
|
|
}
|
|
if expectedError != "" && !strings.Contains(output, expectedError) {
|
|
t.Fatalf("%s: expected error output to contain %q, got: %s", testName, expectedError, output)
|
|
}
|
|
}
|
|
|
|
// createTestGzipFile creates a gzip file with given content for testing
|
|
func createTestGzipFile(t TestingInterface, path string, content []byte) {
|
|
// Validate path is safe for test file creation
|
|
if !strings.Contains(path, os.TempDir()) && !strings.Contains(path, shared.TestDataDir) {
|
|
t.Fatalf("Test file path must be in temp directory or testdata: %s", path)
|
|
}
|
|
|
|
// #nosec G304 - This is test code creating files in controlled test locations
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create gzip file: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := f.Close(); err != nil {
|
|
t.Fatalf("Failed to close file: %v", err)
|
|
}
|
|
}()
|
|
|
|
gz := gzip.NewWriter(f)
|
|
|
|
_, err = gz.Write(content)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write gzip content: %v", err)
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
t.Fatalf("Failed to close gzip writer: %v", err)
|
|
}
|
|
}
|
|
|
|
// setupTempDirWithFiles creates a temp directory with multiple test files
|
|
func setupTempDirWithFiles(t TestingInterface, files map[string][]byte) string {
|
|
tempDir := t.TempDir()
|
|
|
|
for filename, content := range files {
|
|
path := filepath.Join(tempDir, filename)
|
|
if err := os.WriteFile(path, content, shared.DefaultFilePermissions); err != nil {
|
|
t.Fatalf("Failed to create file %s: %v", filename, err)
|
|
}
|
|
}
|
|
|
|
return tempDir
|
|
}
|
|
|
|
// validateTestDataFile checks if a test data file exists and returns its absolute path
|
|
func validateTestDataFile(t *testing.T, testDataFile string) string {
|
|
t.Helper()
|
|
absTestLogFile, err := filepath.Abs(testDataFile)
|
|
if err != nil {
|
|
t.Fatalf(shared.ErrFailedToGetAbsPath, err)
|
|
}
|
|
if _, err := os.Stat(absTestLogFile); os.IsNotExist(err) {
|
|
t.Skipf(shared.ErrTestDataNotFound, absTestLogFile)
|
|
}
|
|
return absTestLogFile
|
|
}
|
|
|
|
// assertMinimumLines checks that result has at least the expected number of lines
|
|
func assertMinimumLines(t *testing.T, lines []string, minimum int, description string) {
|
|
t.Helper()
|
|
if len(lines) < minimum {
|
|
t.Errorf("Expected at least %d %s, got %d", minimum, description, len(lines))
|
|
}
|
|
}
|
|
|
|
// assertContainsText checks that at least one line contains the expected text
|
|
func assertContainsText(t *testing.T, lines []string, text string) {
|
|
t.Helper()
|
|
for _, line := range lines {
|
|
if strings.Contains(line, text) {
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("Expected to find '%s' in results", text)
|
|
}
|
|
|
|
// WithTestRunner sets a test runner and returns a cleanup function.
|
|
// Usage: defer fail2ban.WithTestRunner(t, mockRunner)()
|
|
func WithTestRunner(t TestingInterface, runner Runner) func() {
|
|
t.Helper()
|
|
original := GetRunner()
|
|
SetRunner(runner)
|
|
return func() { SetRunner(original) }
|
|
}
|
|
|
|
// WithTestSudoChecker sets a test sudo checker and returns a cleanup function.
|
|
// Usage: defer fail2ban.WithTestSudoChecker(t, mockChecker)()
|
|
func WithTestSudoChecker(t TestingInterface, checker SudoChecker) func() {
|
|
t.Helper()
|
|
original := GetSudoChecker()
|
|
SetSudoChecker(checker)
|
|
return func() { SetSudoChecker(original) }
|
|
}
|
|
|
|
// StandardMockSetup configures comprehensive standard responses for MockRunner
|
|
// This eliminates the need for repetitive SetResponse calls in individual tests
|
|
func StandardMockSetup(mockRunner *MockRunner) {
|
|
// Version responses
|
|
mockRunner.SetResponse("fail2ban-client -V", []byte(shared.MockVersion))
|
|
mockRunner.SetResponse("sudo fail2ban-client -V", []byte(shared.MockVersion))
|
|
|
|
// Ping responses
|
|
mockRunner.SetResponse("fail2ban-client ping", []byte(shared.PingOutput))
|
|
mockRunner.SetResponse("sudo fail2ban-client ping", []byte(shared.PingOutput))
|
|
|
|
// Status responses
|
|
statusResponse := "Status\n|- Number of jail: 2\n`- Jail list: sshd, apache"
|
|
mockRunner.SetResponse("fail2ban-client status", []byte(statusResponse))
|
|
mockRunner.SetResponse("sudo fail2ban-client status", []byte(statusResponse))
|
|
|
|
// Individual jail status responses
|
|
sshdStatus := "Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t0\n| " +
|
|
"|- Total failed:\t5\n| `- File list:\t/var/log/auth.log\n`- Actions\n " +
|
|
"|- Currently banned:\t1\n |- Total banned:\t2\n `- Banned IP list:\t192.168.1.100"
|
|
|
|
mockRunner.SetResponse(shared.MockCommandStatusSSHD, []byte(sshdStatus))
|
|
mockRunner.SetResponse("sudo "+shared.MockCommandStatusSSHD, []byte(sshdStatus))
|
|
|
|
apacheStatus := "Status for the jail: apache\n|- Filter\n| |- Currently failed:\t0\n| " +
|
|
"|- Total failed:\t3\n| `- File list:\t/var/log/apache2/error.log\n`- Actions\n " +
|
|
"|- Currently banned:\t0\n |- Total banned:\t1\n `- Banned IP list:\t"
|
|
|
|
mockRunner.SetResponse(shared.MockCommandStatusApache, []byte(apacheStatus))
|
|
mockRunner.SetResponse("sudo "+shared.MockCommandStatusApache, []byte(apacheStatus))
|
|
|
|
// Ban/unban responses
|
|
mockRunner.SetResponse(shared.MockCommandBanIP, []byte(shared.Fail2BanStatusSuccess))
|
|
mockRunner.SetResponse("sudo "+shared.MockCommandBanIP, []byte(shared.Fail2BanStatusSuccess))
|
|
mockRunner.SetResponse(shared.MockCommandUnbanIP, []byte(shared.Fail2BanStatusSuccess))
|
|
mockRunner.SetResponse("sudo "+shared.MockCommandUnbanIP, []byte(shared.Fail2BanStatusSuccess))
|
|
|
|
mockRunner.SetResponse("fail2ban-client set apache banip 192.168.1.101", []byte(shared.Fail2BanStatusSuccess))
|
|
mockRunner.SetResponse("sudo fail2ban-client set apache banip 192.168.1.101", []byte(shared.Fail2BanStatusSuccess))
|
|
mockRunner.SetResponse("fail2ban-client set apache unbanip 192.168.1.101", []byte(shared.Fail2BanStatusSuccess))
|
|
mockRunner.SetResponse(
|
|
"sudo fail2ban-client set apache unbanip 192.168.1.101",
|
|
[]byte(shared.Fail2BanStatusSuccess),
|
|
)
|
|
|
|
// Banned IP responses
|
|
mockRunner.SetResponse("fail2ban-client banned 192.168.1.100", []byte(shared.MockBannedOutput))
|
|
mockRunner.SetResponse("sudo fail2ban-client banned 192.168.1.100", []byte(shared.MockBannedOutput))
|
|
mockRunner.SetResponse("fail2ban-client banned 192.168.1.101", []byte("[]"))
|
|
mockRunner.SetResponse("sudo fail2ban-client banned 192.168.1.101", []byte("[]"))
|
|
}
|
|
|
|
// SetupMockEnvironmentWithStandardResponses combines mock environment setup with standard responses
|
|
// This is a convenience function for tests that need comprehensive mock responses
|
|
func SetupMockEnvironmentWithStandardResponses(t TestingInterface) (client *MockClient, cleanup func()) {
|
|
t.Helper()
|
|
|
|
client, cleanup = SetupMockEnvironment(t)
|
|
|
|
// Safe type assertion with error handling
|
|
mockRunner, ok := GetRunner().(*MockRunner)
|
|
if !ok {
|
|
t.Fatalf("Expected GetRunner() to return *MockRunner, got %T", GetRunner())
|
|
}
|
|
|
|
StandardMockSetup(mockRunner)
|
|
|
|
return client, cleanup
|
|
}
|