mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
* feat: Allow cache reading from replicas * 🍱 fix logic * ✨ add testing for redis with mock * 🍱 fix permission * 📝 test(e2e/redis): fix swapped IP→verdict comments The mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5, and the run.sh assertions match that. Both doc comments described the opposite mapping; correct them to match the code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ✅ test(e2e/redis): exercise read-from-replica path The redis scenario only set redisCacheHost, so it validated the writer but never the round-robin reader path this feature adds. Split the mock into two roles: the primary (--redis-addr) now answers every GET with a miss, while the replica (--redis-read-addr) serves the hardcoded verdicts. The scenario points redisCacheReadHosts at the replica (twice, to drive round-robin), so the banned-IP-blocked assertion only passes if the plugin actually reads decisions from the replica rather than the primary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 📝 docs: note replicas don't fall back to primary on outage When RedisCacheReadHosts is set, reads are not retried against the primary if the replicas are unreachable. Document that this, combined with the default RedisCacheUnreachableBlock=true, means a replica outage can block traffic while the primary is healthy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🐛 fix(cache): avoid nil-pointer panic on empty redis read redisCache.get fell through to `switch err.Error()` when Get returned a nil error with an empty value, panicking on the nil error. simpleredis never returns that combination today (a miss yields RedisMiss), so it was unreachable in practice — but the read path is safer treating an empty, error-free read as a cache miss, which also guarantees err is non-nil before err.Error() is called. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 🍱 add test for rotation * 🐛 readd redis/run.sh * 🐛 fix redis/run.sh * 🐛 fix test label; remove log * 🍱 add tests for roundRobin * 🐛 fix test --------- Co-authored-by: maxlerebourg <maxlerebourg@gmail.com> Co-authored-by: mhx <mathieu@hanotaux.fr> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
163 lines
5.0 KiB
Go
163 lines
5.0 KiB
Go
// Package cache implements utility routines for manipulating cache.
|
|
// It supports currently local file and redis cache.
|
|
package cache
|
|
|
|
import (
|
|
"testing"
|
|
|
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
simpleredis "github.com/maxlerebourg/simpleredis"
|
|
)
|
|
|
|
func Test_Get(t *testing.T) {
|
|
IPInCache := "10.0.0.10"
|
|
IPNotInCache := "10.0.0.20"
|
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
|
client.Set(IPInCache, BannedValue, 10)
|
|
type args struct {
|
|
clientIP string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want string
|
|
wantErr bool
|
|
valueErr string
|
|
}{
|
|
{name: "Fetch Known valid IP", args: args{clientIP: IPInCache}, want: BannedValue, wantErr: false, valueErr: ""},
|
|
{name: "Fetch Unknown valid IP", args: args{clientIP: IPNotInCache}, want: "", wantErr: true, valueErr: CacheMiss},
|
|
{name: "Fetch invalid value", args: args{clientIP: "test"}, want: "", wantErr: true, valueErr: CacheMiss},
|
|
{name: "Fetch empty value", args: args{clientIP: ""}, want: "", wantErr: true, valueErr: CacheMiss},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := client.Get(tt.args.clientIP)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("Get() = %v, want %v", got, tt.want)
|
|
return
|
|
}
|
|
if tt.valueErr != "" && tt.valueErr != err.Error() {
|
|
t.Errorf("Get() err = %v, want %v", err.Error(), tt.valueErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_Set(t *testing.T) {
|
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
|
IPInCache := "10.0.0.11"
|
|
type args struct {
|
|
clientIP string
|
|
value string
|
|
duration int64
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want string
|
|
wantErr bool
|
|
valueErr string
|
|
}{
|
|
{name: "Set valid IP in local cache for 0 sec", args: args{clientIP: IPInCache, value: BannedValue, duration: 0}, want: "", wantErr: true, valueErr: CacheMiss},
|
|
{name: "Set valid IP in local cache for 10 sec", args: args{clientIP: IPInCache, value: BannedValue, duration: 10}, want: BannedValue, wantErr: false, valueErr: ""},
|
|
{name: "Set valid IP in local cache for 10 sec", args: args{clientIP: IPInCache, value: NoBannedValue, duration: 10}, want: NoBannedValue, wantErr: false, valueErr: ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
client.Set(tt.args.clientIP, tt.args.value, tt.args.duration)
|
|
got, err := client.Get(tt.args.clientIP)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("Set() = %v, want %v", got, tt.want)
|
|
return
|
|
}
|
|
if tt.valueErr != "" && tt.valueErr != err.Error() {
|
|
t.Errorf("Set() err = %v, want %v", err.Error(), tt.valueErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_Delete(t *testing.T) {
|
|
IPInCache := "10.0.0.12"
|
|
IPNotInCache := "10.0.0.22"
|
|
client := &Client{cache: &localCache{}, log: logger.New("INFO", "")}
|
|
client.Set(IPInCache, BannedValue, 10)
|
|
type args struct {
|
|
clientIP string
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want string
|
|
wantErr bool
|
|
valueErr string
|
|
}{
|
|
{name: "Delete Known valid IP", args: args{clientIP: IPInCache}, want: "", wantErr: true, valueErr: CacheMiss},
|
|
{name: "Delete Unknown valid IP", args: args{clientIP: IPNotInCache}, want: "", wantErr: true, valueErr: CacheMiss},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
client.Delete(tt.args.clientIP)
|
|
got, err := client.Get(tt.args.clientIP)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Delete() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("Delete() = %v, want %v", got, tt.want)
|
|
return
|
|
}
|
|
if tt.valueErr != "" && tt.valueErr != err.Error() {
|
|
t.Errorf("Delete() err = %v, want %v", err.Error(), tt.valueErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// indexOfReader returns the position of r inside rc.readers, or -1 when r is the writer (the no-readers fallback).
|
|
func indexOfReader(rc *redisCache, r *simpleredis.SimpleRedis) int {
|
|
if r == &rc.writer {
|
|
return -1
|
|
}
|
|
for i := range rc.readers {
|
|
if r == &rc.readers[i] {
|
|
return i
|
|
}
|
|
}
|
|
return -2
|
|
}
|
|
|
|
func Test_nextReader(t *testing.T) {
|
|
// The counter starts at 0, so the first Add(1) yields index 1, then 2, 0, 1, ... over n readers.
|
|
tests := []struct {
|
|
name string
|
|
readers int
|
|
want []int
|
|
}{
|
|
{name: "round-robin over three readers", readers: 3, want: []int{1, 2, 0, 1, 2, 0, 1}},
|
|
{name: "single reader always selected", readers: 1, want: []int{0, 0, 0, 0, 0}},
|
|
{name: "no readers fall back to writer", readers: 0, want: []int{-1, -1, -1}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rc := &redisCache{log: logger.New("INFO", "")}
|
|
rc.readers = make([]simpleredis.SimpleRedis, tt.readers)
|
|
for call, want := range tt.want {
|
|
if got := indexOfReader(rc, rc.nextReader()); got != want {
|
|
t.Errorf("call %d: nextReader() -> reader[%d], want reader[%d]", call, got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|