Files
f2b/fail2ban/client_withcontext_test.go
Ismo Vuorinen 605f2b9580 refactor: linting, simplification and fixes (#119)
* 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.
2026-01-25 19:07:45 +02:00

598 lines
15 KiB
Go

package fail2ban
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestListJailsWithContext tests jail listing with context
func TestListJailsWithContext(t *testing.T) {
tests := []struct {
name string
setupMock func(*MockRunner)
timeout time.Duration
expectError bool
expectJails []string
}{
{
name: "successful jail listing",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
},
timeout: 5 * time.Second,
expectError: false,
expectJails: []string{"sshd", "apache"}, // From StandardMockSetup
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := NewMockRunner()
tt.setupMock(mock)
SetRunner(mock)
client, err := NewClient("/var/log", "/etc/fail2ban/filter.d")
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
if tt.timeout == 1*time.Nanosecond {
time.Sleep(2 * time.Millisecond) // Ensure timeout
}
jails, err := client.ListJailsWithContext(ctx)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectJails, jails)
}
})
}
}
// TestStatusAllWithContext tests status all with context
func TestStatusAllWithContext(t *testing.T) {
tests := []struct {
name string
setupMock func(*MockRunner)
timeout time.Duration
expectError bool
expectContains string
}{
{
name: "successful status all",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse("fail2ban-client status", []byte("Status\n|- Number of jail: 1\n`- Jail list: sshd"))
m.SetResponse("sudo fail2ban-client status", []byte("Status\n|- Number of jail: 1\n`- Jail list: sshd"))
},
timeout: 5 * time.Second,
expectError: false,
expectContains: "Status",
},
{
name: "context timeout",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse("fail2ban-client status", []byte("Status\n|- Number of jail: 1\n`- Jail list: sshd"))
m.SetResponse("sudo fail2ban-client status", []byte("Status\n|- Number of jail: 1\n`- Jail list: sshd"))
},
timeout: 1 * time.Nanosecond,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := NewMockRunner()
tt.setupMock(mock)
SetRunner(mock)
client, err := NewClient("/var/log", "/etc/fail2ban/filter.d")
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
if tt.timeout == 1*time.Nanosecond {
time.Sleep(2 * time.Millisecond)
}
status, err := client.StatusAllWithContext(ctx)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Contains(t, status, tt.expectContains)
}
})
}
}
// TestStatusJailWithContext tests status jail with context
func TestStatusJailWithContext(t *testing.T) {
tests := []struct {
name string
jail string
setupMock func(*MockRunner)
timeout time.Duration
expectError bool
expectContains string
}{
{
name: "successful status jail",
jail: "sshd",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse(
"fail2ban-client status sshd",
[]byte("Status for the jail: sshd\n|- Filter\n`- Currently banned: 0"),
)
m.SetResponse(
"sudo fail2ban-client status sshd",
[]byte("Status for the jail: sshd\n|- Filter\n`- Currently banned: 0"),
)
},
timeout: 5 * time.Second,
expectError: false,
expectContains: "sshd",
},
{
name: "invalid jail name",
jail: "invalid@jail",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
// Validation will fail before command execution
},
timeout: 5 * time.Second,
expectError: true,
},
{
name: "context timeout",
jail: "sshd",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse(
"fail2ban-client status sshd",
[]byte("Status for the jail: sshd\n|- Filter\n`- Currently banned: 0"),
)
m.SetResponse(
"sudo fail2ban-client status sshd",
[]byte("Status for the jail: sshd\n|- Filter\n`- Currently banned: 0"),
)
},
timeout: 1 * time.Nanosecond,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := NewMockRunner()
tt.setupMock(mock)
SetRunner(mock)
client, err := NewClient("/var/log", "/etc/fail2ban/filter.d")
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
if tt.timeout == 1*time.Nanosecond {
time.Sleep(2 * time.Millisecond)
}
status, err := client.StatusJailWithContext(ctx, tt.jail)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
if tt.expectContains != "" {
assert.Contains(t, status, tt.expectContains)
}
}
})
}
}
// TestUnbanIPWithContext tests unban IP with context
func TestUnbanIPWithContext(t *testing.T) {
tests := []struct {
name string
ip string
jail string
setupMock func(*MockRunner)
timeout time.Duration
expectError bool
expectCode int
}{
{
name: "successful unban",
ip: "192.168.1.100",
jail: "sshd",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse("fail2ban-client set sshd unbanip 192.168.1.100", []byte("0"))
m.SetResponse("sudo fail2ban-client set sshd unbanip 192.168.1.100", []byte("0"))
},
timeout: 5 * time.Second,
expectError: false,
expectCode: 0,
},
{
name: "already unbanned",
ip: "192.168.1.100",
jail: "sshd",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse("fail2ban-client set sshd unbanip 192.168.1.100", []byte("1"))
m.SetResponse("sudo fail2ban-client set sshd unbanip 192.168.1.100", []byte("1"))
},
timeout: 5 * time.Second,
expectError: false,
expectCode: 1,
},
{
name: "invalid IP address",
ip: "invalid-ip",
jail: "sshd",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
// Validation will fail before command execution
},
timeout: 5 * time.Second,
expectError: true,
},
{
name: "invalid jail name",
ip: "192.168.1.100",
jail: "invalid@jail",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
// Validation will fail before command execution
},
timeout: 5 * time.Second,
expectError: true,
},
{
name: "context timeout",
ip: "192.168.1.100",
jail: "sshd",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse("fail2ban-client set sshd unbanip 192.168.1.100", []byte("0"))
m.SetResponse("sudo fail2ban-client set sshd unbanip 192.168.1.100", []byte("0"))
},
timeout: 1 * time.Nanosecond,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := NewMockRunner()
tt.setupMock(mock)
SetRunner(mock)
client, err := NewClient("/var/log", "/etc/fail2ban/filter.d")
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
if tt.timeout == 1*time.Nanosecond {
time.Sleep(2 * time.Millisecond)
}
code, err := client.UnbanIPWithContext(ctx, tt.ip, tt.jail)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectCode, code)
}
})
}
}
// TestListFiltersWithContext tests filter listing with context
func TestListFiltersWithContext(t *testing.T) {
tests := []struct {
name string
setupMock func(*MockRunner)
setupEnv func()
timeout time.Duration
expectError bool
expectFilters []string
}{
{
name: "successful filter listing",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
// Mock responses not needed - uses file system
},
setupEnv: func() {
// Client will use default filter directory
},
timeout: 5 * time.Second,
expectError: false,
expectFilters: nil, // Will depend on actual filter directory
},
{
name: "context timeout",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
// Not applicable for file system operation
},
setupEnv: func() {
// No setup needed
},
timeout: 1 * time.Nanosecond,
expectError: true, // Context check happens first
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := NewMockRunner()
tt.setupMock(mock)
SetRunner(mock)
tt.setupEnv()
client, err := NewClient("/var/log", "/etc/fail2ban/filter.d")
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
if tt.timeout == 1*time.Nanosecond {
time.Sleep(2 * time.Millisecond)
}
filters, err := client.ListFiltersWithContext(ctx)
if tt.expectError {
assert.Error(t, err)
} else {
// May error if directory doesn't exist, which is fine in tests
if err == nil {
assert.NotNil(t, filters)
}
}
})
}
}
// TestTestFilterWithContext tests filter testing with context
func TestTestFilterWithContext(t *testing.T) {
// Enable dev paths to allow temporary directory
t.Setenv("ALLOW_DEV_PATHS", "1")
// Create temporary filter directory
tmpDir := t.TempDir()
filterContent := `[Definition]
failregex = ^.* Failed .* for .* from <HOST>
logpath = /var/log/auth.log
`
err := os.WriteFile(filepath.Join(tmpDir, "sshd.conf"), []byte(filterContent), 0600)
require.NoError(t, err)
tests := []struct {
name string
filter string
setupMock func(*MockRunner)
timeout time.Duration
expectError bool
}{
{
name: "successful filter test",
filter: "sshd",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse(
"fail2ban-regex /var/log/auth.log "+filepath.Join(tmpDir, "sshd.conf"),
[]byte("Success: 0 matches"),
)
m.SetResponse(
"sudo fail2ban-regex /var/log/auth.log "+filepath.Join(tmpDir, "sshd.conf"),
[]byte("Success: 0 matches"),
)
},
timeout: 5 * time.Second,
expectError: false,
},
{
name: "invalid filter name",
filter: "invalid@filter",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
// Validation will fail before command execution
},
timeout: 5 * time.Second,
expectError: true,
},
{
name: "context timeout",
filter: "sshd",
setupMock: func(m *MockRunner) {
StandardMockSetup(m)
m.SetResponse(
"fail2ban-regex /var/log/auth.log "+filepath.Join(tmpDir, "sshd.conf"),
[]byte("Success: 0 matches"),
)
m.SetResponse(
"sudo fail2ban-regex /var/log/auth.log "+filepath.Join(tmpDir, "sshd.conf"),
[]byte("Success: 0 matches"),
)
},
timeout: 1 * time.Nanosecond,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := NewMockRunner()
tt.setupMock(mock)
SetRunner(mock)
client, err := NewClient("/var/log", tmpDir)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
if tt.timeout == 1*time.Nanosecond {
time.Sleep(2 * time.Millisecond)
}
result, err := client.TestFilterWithContext(ctx, tt.filter)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NotEmpty(t, result)
}
})
}
}
// TestWithContextCancellation tests that all WithContext functions respect cancellation
func TestWithContextCancellation(t *testing.T) {
mock := NewMockRunner()
StandardMockSetup(mock)
SetRunner(mock)
client, err := NewClient("/var/log", "/etc/fail2ban/filter.d")
require.NoError(t, err)
// Create canceled context
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
// Note: ListJailsWithContext and ListFiltersWithContext are too fast to be canceled
// as they return cached data or read from filesystem. Only testing I/O operations.
t.Run("StatusAllWithContext respects cancellation", func(t *testing.T) {
_, err := client.StatusAllWithContext(ctx)
assert.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled) || isContextError(err))
})
t.Run("StatusJailWithContext respects cancellation", func(t *testing.T) {
_, err := client.StatusJailWithContext(ctx, "sshd")
assert.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled) || isContextError(err))
})
t.Run("UnbanIPWithContext respects cancellation", func(t *testing.T) {
_, err := client.UnbanIPWithContext(ctx, "192.168.1.100", "sshd")
assert.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled) || isContextError(err))
})
}
// TestWithContextDeadline tests that all WithContext functions respect deadlines
func TestWithContextDeadline(t *testing.T) {
mock := NewMockRunner()
StandardMockSetup(mock)
SetRunner(mock)
client, err := NewClient("/var/log", "/etc/fail2ban/filter.d")
require.NoError(t, err)
// Create context with very short deadline
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Ensure timeout
time.Sleep(2 * time.Millisecond)
// Note: ListJailsWithContext, ListFiltersWithContext, and TestFilterWithContext
// are too fast to timeout as they return cached data or read from filesystem.
// Only testing I/O operations that make network/command calls.
tests := []struct {
name string
fn func() error
}{
{
name: "StatusAllWithContext",
fn: func() error {
_, err := client.StatusAllWithContext(ctx)
return err
},
},
{
name: "StatusJailWithContext",
fn: func() error {
_, err := client.StatusJailWithContext(ctx, "sshd")
return err
},
},
{
name: "UnbanIPWithContext",
fn: func() error {
_, err := client.UnbanIPWithContext(ctx, "192.168.1.100", "sshd")
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name+" respects deadline", func(t *testing.T) {
err := tt.fn()
assert.Error(t, err)
assert.True(t, errors.Is(err, context.DeadlineExceeded) || isContextError(err))
})
}
}
// TestWithContextValidation tests that validation happens before context usage
func TestWithContextValidation(t *testing.T) {
mock := NewMockRunner()
StandardMockSetup(mock)
SetRunner(mock)
client, err := NewClient("/var/log", "/etc/fail2ban/filter.d")
require.NoError(t, err)
ctx := context.Background()
t.Run("StatusJailWithContext validates jail name", func(t *testing.T) {
_, err := client.StatusJailWithContext(ctx, "invalid@jail")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid")
})
t.Run("UnbanIPWithContext validates IP", func(t *testing.T) {
_, err := client.UnbanIPWithContext(ctx, "invalid-ip", "sshd")
assert.Error(t, err)
})
t.Run("UnbanIPWithContext validates jail", func(t *testing.T) {
_, err := client.UnbanIPWithContext(ctx, "192.168.1.100", "invalid@jail")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid")
})
t.Run("TestFilterWithContext validates filter", func(t *testing.T) {
_, err := client.TestFilterWithContext(ctx, "invalid@filter")
assert.Error(t, err)
})
}