Repository: tomMoulard/fail2ban
Pull request #188: Do not increment failure counter for successful requests
Author: login: jhalag
Created: 2025-03-07T15:54:33Z | Merged: 2025-03-31T09:29:04Z

fail2ban/handler was incrementing fail count for all HTTP calls, not just failed calls. Add new function IsNotBanned() to allow checking of IP ban status without incrementing fail count.

Update tests to address time.Now() not matching between expected and actual. This error was present prior to this fork.

Addresses https://github.com/tomMoulard/fail2ban/issues/136

(replaces #184 - source branch for PR changed, which broke the original PR)


Diff:
diff --git a/Makefile b/Makefile
index 2bde376c..4e5b68ee 100644
--- a/Makefile
+++ b/Makefile
@@ -12,7 +12,7 @@ lint:
 	go tool golangci-lint run
 
 .PHONY: test
-TEST_ARGS ?= -v -cover -race -tags DEBUG,TEST
+TEST_ARGS ?= -cover -race -tags DEBUG,TEST
 test:
 	go test ${TEST_ARGS} ./...
 
diff --git a/fail2ban_test.go b/fail2ban_test.go
index c4bc1dc0..2e7b9a30 100644
--- a/fail2ban_test.go
+++ b/fail2ban_test.go
@@ -6,10 +6,12 @@ import (
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"strconv"
 	"strings"
 	"sync/atomic"
 	"testing"
 
+	"github.com/stretchr/testify/assert"
 	"github.com/tomMoulard/fail2ban/pkg/rules"
 	"golang.org/x/net/websocket"
 )
@@ -394,3 +396,87 @@ func TestDeadlockWebsocket(t *testing.T) {
 		t.Errorf("wanted %d got %d", 10, concurentWSCount.Load())
 	}
 }
+
+func TestFail2Ban_SuccessiveRequests(t *testing.T) {
+	t.Parallel()
+
+	remoteAddr := "10.0.0.0"
+	tests := []struct {
+		name          string
+		cfg           *Config
+		handlerStatus []int // HTTP code the internal HTTP handler should return
+		expectStatus  []int // HTTP code the downstream client should request after passing through fail2ban
+	}{
+		{
+			name: "rule enabled, 200 code does not increment count or ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			// multiple OKs in a row should not result in a ban
+			handlerStatus: []int{http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+		},
+		{
+			name: "rule enabled, single 404 does not ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			handlerStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+		},
+		{
+			name: "rule enabled, multiple 404 causes ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			// the remaining OKs will not reach the client as it is banned
+			handlerStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusNotFound, http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusNotFound, http.StatusOK, http.StatusNotFound, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+
+			next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				testno, err := strconv.Atoi(r.Header.Get("Testno"))
+				assert.NoError(t, err)
+
+				w.WriteHeader(testno)
+			})
+
+			handler, _ := New(t.Context(), next, test.cfg, "fail2ban_test")
+
+			req := httptest.NewRequest(http.MethodGet, "/", nil)
+			req.RemoteAddr = remoteAddr + ":1234"
+
+			for i := range test.handlerStatus {
+				rw := httptest.NewRecorder()
+
+				req.Header.Set("Testno", strconv.Itoa(test.handlerStatus[i])) // pass the expected value to the mock handler (fail2ban response code may differ)
+				handler.ServeHTTP(rw, req)
+
+				assert.Equal(t, test.expectStatus[i], rw.Code, "request [%d] code", i)
+			}
+		})
+	}
+}
diff --git a/pkg/fail2ban/fail2ban.go b/pkg/fail2ban/fail2ban.go
index 8ddce080..935d3a8f 100644
--- a/pkg/fail2ban/fail2ban.go
+++ b/pkg/fail2ban/fail2ban.go
@@ -28,6 +28,7 @@ func New(rules rules.RulesTransformed) *Fail2Ban {
 }
 
 // ShouldAllow check if the request should be allowed.
+// Called when a request was DENIED - increments the denied counter.
 func (u *Fail2Ban) ShouldAllow(remoteIP string) bool {
 	u.MuIP.Lock()
 	defer u.MuIP.Unlock()
@@ -106,3 +107,52 @@ func (u *Fail2Ban) ShouldAllow(remoteIP string) bool {
 
 	return true
 }
+
+// IsNotBanned Non-incrementing check to see if an IP is already banned.
+func (u *Fail2Ban) IsNotBanned(remoteIP string) bool {
+	u.MuIP.Lock()
+	defer u.MuIP.Unlock()
+
+	ip, foundIP := u.IPs[remoteIP]
+
+	// Fail2Ban
+	if !foundIP {
+		u.IPs[remoteIP] = ipchecking.IPViewed{
+			Viewed: utime.Now(),
+			Count:  0,
+		}
+
+		fmt.Printf("welcome %q", remoteIP)
+
+		return true
+	}
+
+	if ip.Denied {
+		if utime.Now().Before(ip.Viewed.Add(u.rules.Bantime)) {
+			u.IPs[remoteIP] = ipchecking.IPViewed{
+				Viewed: utime.Now(), // refresh ban time
+				Count:  ip.Count + 1,
+				Denied: true,
+			}
+
+			fmt.Printf("%q is still banned since %q, %d request",
+				remoteIP, ip.Viewed.Format(time.RFC3339), ip.Count+1)
+
+			return false
+		}
+
+		u.IPs[remoteIP] = ipchecking.IPViewed{
+			Viewed: utime.Now(),
+			Count:  1,
+			Denied: false,
+		}
+
+		fmt.Println(remoteIP + " is no longer banned")
+
+		return true
+	}
+
+	fmt.Printf("welcome back %q", remoteIP)
+
+	return true
+}
diff --git a/pkg/fail2ban/handler/handler.go b/pkg/fail2ban/handler/handler.go
index 61778caf..4999782c 100644
--- a/pkg/fail2ban/handler/handler.go
+++ b/pkg/fail2ban/handler/handler.go
@@ -26,7 +26,7 @@ func (h *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) (*chain.S
 		return nil, errors.New("failed to get data from request context")
 	}
 
-	if !h.f2b.ShouldAllow(data.RemoteIP) {
+	if !h.f2b.IsNotBanned(data.RemoteIP) {
 		return &chain.Status{Return: true}, nil
 	}
 
diff --git a/pkg/response/status/status.go b/pkg/response/status/status.go
index eb96220e..6e70ba7f 100644
--- a/pkg/response/status/status.go
+++ b/pkg/response/status/status.go
@@ -46,7 +46,7 @@ func (s *status) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	fmt.Printf("catcher: %+v", *catcher)
 
-	if !catcher.isFilteredCode() {
+	if !catcher.isFilteredCode() { // if this is not a status code of concern: Return and do not increment fail counter.
 		w.WriteHeader(catcher.getCode())
 
 		return
diff --git a/pkg/response/status/status_test.go b/pkg/response/status/status_test.go
index d1af0991..4e267802 100644
--- a/pkg/response/status/status_test.go
+++ b/pkg/response/status/status_test.go
@@ -128,7 +128,17 @@ func TestStatus(t *testing.T) {
 			d.ServeHTTP(recorder, req)
 			t.Logf("recorder: %+v", recorder)
 
-			assert.Equal(t, test.expectedIPViewed, f2b.IPs)
+			require.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))
+
+			// workaround for time.Now() not matching between expected and actual
+			for k, v := range test.expectedIPViewed {
+				assert.Contains(t, f2b.IPs, k)
+
+				// copy timestamp, as it will not match otherwise. Then compare
+				v.Viewed = f2b.IPs[k].Viewed
+				assert.Equal(t, v, f2b.IPs[k])
+			}
+
 			assert.Equal(t, test.expectedStatus, recorder.Code)
 			require.NotNil(t, recorder.Body)
 			assert.Equal(t, test.expectedBody, recorder.Body.String())
diff --git a/pkg/url/deny/deny_test.go b/pkg/url/deny/deny_test.go
index f851925f..4642dec8 100644
--- a/pkg/url/deny/deny_test.go
+++ b/pkg/url/deny/deny_test.go
@@ -60,7 +60,16 @@ func TestDeny(t *testing.T) {
 			got, err := d.ServeHTTP(recorder, req)
 			require.NoError(t, err)
 			assert.Equal(t, test.expectedStatus, got)
-			assert.Equal(t, test.expectedIPViewed, f2b.IPs)
+			require.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))
+
+			// workaround for time.Now() not matching between expected and actual
+			for k, v := range test.expectedIPViewed {
+				assert.Contains(t, f2b.IPs, k)
+
+				// copy timestamp, as it will not match otherwise. Then compare
+				v.Viewed = f2b.IPs[k].Viewed
+				assert.Equal(t, v, f2b.IPs[k])
+			}
 		})
 	}
 }


Reviews:
user: login: ildyria
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T22:28:06Z
user: login: ildyria
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T22:29:24Z
user: login: ildyria
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T22:30:28Z
user: login: jhalag
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T01:09:49Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR prevents incrementing the failure counter for successful requests by introducing a non-incrementing check through the new IsNotBanned function while updating tests to handle time discrepancies.  
- Updated test files in pkg/url/deny/deny_test.go and pkg/response/status/status_test.go to workaround timestamp mismatches.  
- Modified the handler in pkg/fail2ban/handler/handler.go to use IsNotBanned for checking ban status without increasing the failure count.  
- Introduced the IsNotBanned function in pkg/fail2ban/fail2ban.go and added tests in fail2ban_test.go for successive requests.

### Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                      | Description                                                         |
| ----------------------------------------- | ------------------------------------------------------------------- |
| pkg/url/deny/deny_test.go                 | Updated tests to compare IP maps with a workaround for timestamps.  |
| pkg/response/status/status_test.go        | Similar timestamp workaround added along with response status checks. |
| pkg/response/status/status.go             | Clarified inline comment for status code handling.                 |
| pkg/fail2ban/handler/handler.go            | Replaced ShouldAllow with IsNotBanned to avoid unintended counter increments. |
| pkg/fail2ban/fail2ban.go                  | Added new IsNotBanned function with logic to reset or increment counters appropriately based on ban status. |
| fail2ban_test.go                          | Added tests to verify successive request handling under the new logic. |
</details>



<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**pkg/url/deny/deny_test.go:66**
* [nitpick] Consider moving the map length assertion outside the loop to avoid redundant checks in every iteration.
```
assert.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))
```
**pkg/response/status/status_test.go:134**
* [nitpick] Consider performing the map length check once before entering the loop to reduce needless repetitive assertions.
```
assert.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))
```
**pkg/fail2ban/handler/handler.go:29**
* [nitpick] The function name 'IsNotBanned' may be confusing due to its double-negative logic; consider renaming it to 'CheckBanStatus' or a similar clearer alternative.
```
if !h.f2b.IsNotBanned(data.RemoteIP) {
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T09:33:01Z
user: login: tomMoulard
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-27T09:42:22Z
user: login: tomMoulard
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-27T09:43:27Z
user: login: tomMoulard
state: APPROVED
author_association: OWNER
submitted_at: 2025-03-31T09:28:55Z

Review comments:
diff_hunk: @@ -27,7 +27,7 @@ func New(rules rules.RulesTransformed) *Fail2Ban {
 	}
 }
 
-// ShouldAllow check if the request should be allowed.
+// ShouldAllow check if the request should be allowed. Called when a request was DENIED - increments the denied counter.
path: pkg/fail2ban/fail2ban.go
user: login: ildyria
body: To improve readability, I would put those on two lines. :)
```suggestion
// ShouldAllow check if the request should be allowed.
// Called when a request was DENIED - increments the denied counter.
```
created_at: 2025-03-07T22:28:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,7 +46,7 @@ func (s *status) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	fmt.Printf("catcher: %+v", *catcher)
 
-	if !catcher.isFilteredCode() {
+	if !catcher.isFilteredCode() { // is this not a status code of concern? Return and do not increment fail counter.
path: pkg/response/status/status.go
user: login: ildyria
body: I am not sure I understand the intent of this comment.
Is it a question to the plugin author?
created_at: 2025-03-07T22:29:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -395,3 +396,91 @@ func TestDeadlockWebsocket(t *testing.T) {
 		t.Errorf("wanted %d got %d", 10, concurentWSCount.Load())
 	}
 }
+
+func TestFail2Ban_SuccessiveRequests(t *testing.T) {
+	t.Parallel()
+
+	remoteAddr := "10.0.0.0"
+	tests := []struct {
+		name          string
+		cfg           *Config
+		handlerStatus []int // HTTP code the internal HTTP handler should return
+		expectStatus  []int // HTTP code the downstream client should request after passing through fail2ban
+	}{
+		{
path: fail2ban_test.go
user: login: ildyria
body: I really like those test, as they do check for the multiple behaviours. :+1: 
created_at: 2025-03-07T22:30:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,7 +46,7 @@ func (s *status) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	fmt.Printf("catcher: %+v", *catcher)
 
-	if !catcher.isFilteredCode() {
+	if !catcher.isFilteredCode() { // is this not a status code of concern? Return and do not increment fail counter.
path: pkg/response/status/status.go
user: login: jhalag
body: I have clarified the comment. It was more to explain the if statement.
created_at: 2025-03-08T01:09:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -128,7 +128,20 @@ func TestStatus(t *testing.T) {
 			d.ServeHTTP(recorder, req)
 			t.Logf("recorder: %+v", recorder)
 
-			assert.Equal(t, test.expectedIPViewed, f2b.IPs)
+			// workaround for time.Now() not matching between expected and actual
+			for k, v := range test.expectedIPViewed {
+				// equal size maps
+				assert.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))
path: pkg/response/status/status_test.go
user: login: tomMoulard
body: ```suggestion
			require.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))

			// workaround for time.Now() not matching between expected and actual
			for k, v := range test.expectedIPViewed {
```
created_at: 2025-03-27T09:41:39Z
author_association: OWNER
diff_hunk: @@ -60,7 +60,19 @@ func TestDeny(t *testing.T) {
 			got, err := d.ServeHTTP(recorder, req)
 			require.NoError(t, err)
 			assert.Equal(t, test.expectedStatus, got)
-			assert.Equal(t, test.expectedIPViewed, f2b.IPs)
+			// workaround for time.Now() not matching between expected and actual
+			for k, v := range test.expectedIPViewed {
+				// equal size maps
+				assert.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))
path: pkg/url/deny/deny_test.go
user: login: tomMoulard
body: ```suggestion
			require.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))

			// workaround for time.Now() not matching between expected and actual
			for k, v := range test.expectedIPViewed {
```
created_at: 2025-03-27T09:42:17Z
author_association: OWNER
diff_hunk: @@ -395,3 +396,91 @@ func TestDeadlockWebsocket(t *testing.T) {
 		t.Errorf("wanted %d got %d", 10, concurentWSCount.Load())
 	}
 }
+
+func TestFail2Ban_SuccessiveRequests(t *testing.T) {
+	t.Parallel()
+
+	remoteAddr := "10.0.0.0"
+	tests := []struct {
+		name          string
+		cfg           *Config
+		handlerStatus []int // HTTP code the internal HTTP handler should return
+		expectStatus  []int // HTTP code the downstream client should request after passing through fail2ban
+	}{
+		{
+			name: "rule enabled, 200 code does not increment count or ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			// multiple OKs in a row should not result in a ban
+			handlerStatus: []int{http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+		},
+		{
+			name: "rule enabled, single 404 does not ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			handlerStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+		},
+		{
+			name: "rule enabled, multiple 404 causes ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			// the remaining OKs will not reach the client as it is banned
+			handlerStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusNotFound, http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusNotFound, http.StatusOK, http.StatusNotFound, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+
+			next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				testno, err := strconv.Atoi(r.Header.Get("Testno"))
+				if err != nil {
+					t.Fatalf("Error parsing header: %v", err)
+				}
path: fail2ban_test.go
user: login: tomMoulard
body: ```suggestion
				require.NoError(t, err)
```
created_at: 2025-03-27T09:43:09Z
author_association: OWNER
diff_hunk: @@ -395,3 +396,91 @@ func TestDeadlockWebsocket(t *testing.T) {
 		t.Errorf("wanted %d got %d", 10, concurentWSCount.Load())
 	}
 }
+
+func TestFail2Ban_SuccessiveRequests(t *testing.T) {
+	t.Parallel()
+
+	remoteAddr := "10.0.0.0"
+	tests := []struct {
+		name          string
+		cfg           *Config
+		handlerStatus []int // HTTP code the internal HTTP handler should return
+		expectStatus  []int // HTTP code the downstream client should request after passing through fail2ban
+	}{
+		{
+			name: "rule enabled, 200 code does not increment count or ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			// multiple OKs in a row should not result in a ban
+			handlerStatus: []int{http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+		},
+		{
+			name: "rule enabled, single 404 does not ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			handlerStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+		},
+		{
+			name: "rule enabled, multiple 404 causes ban",
+			cfg: &Config{
+				Rules: rules.Rules{
+					Enabled:    true,
+					Bantime:    "300s",
+					Findtime:   "300s",
+					Maxretry:   3,
+					StatusCode: "404",
+				},
+			},
+			// the remaining OKs will not reach the client as it is banned
+			handlerStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusNotFound, http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
+			expectStatus:  []int{http.StatusNotFound, http.StatusOK, http.StatusNotFound, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+
+			next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				testno, err := strconv.Atoi(r.Header.Get("Testno"))
+				if err != nil {
+					t.Fatalf("Error parsing header: %v", err)
+				}
+
+				w.WriteHeader(testno)
+			})
+
+			handler, _ := New(context.Background(), next, test.cfg, "fail2ban_test")
+
+			req := httptest.NewRequest(http.MethodGet, "/", nil)
+			req.RemoteAddr = remoteAddr + ":1234"
+
+			for i := range test.handlerStatus {
+				rw := httptest.NewRecorder()
+
+				req.Header.Set("Testno", strconv.Itoa(test.handlerStatus[i])) // pass the expected value to the mock handler (fail2ban response code may differ)
+				handler.ServeHTTP(rw, req)
+
+				if rw.Code != test.expectStatus[i] {
+					t.Fatalf("request [%d] code: got %d, expected %d", i, rw.Code, test.expectStatus[i])
+				}
path: fail2ban_test.go
user: login: tomMoulard
body: ```suggestion
				assert.Equal(t, test.expectStatus[i], rw.Code, "request [%d] code", i)
```
created_at: 2025-03-27T09:43:24Z
author_association: OWNER

PR comments:
user: login: ildyria
created_at: 2025-03-27T09:25:47Z
body: @tomMoulard  Any chances this can be merged soon? I would love to use your plugin but this is blocking.
author_association: CONTRIBUTOR
user: login: ildyria
created_at: 2025-03-31T10:52:42Z
body: @tomMoulard @jhalag thank you for this. 🎉🎉🎉
author_association: CONTRIBUTOR