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>
This commit is contained in:
mhx
2026-07-25 21:21:20 +02:00
co-authored by Claude Opus 4.8
parent 8eceba35b7
commit 96beb9192e
4 changed files with 32 additions and 16 deletions
+3
View File
@@ -21,6 +21,7 @@ LAPI_PORT="${LAPI_PORT:-8090}"
BACKEND_PORT="${BACKEND_PORT:-8091}" BACKEND_PORT="${BACKEND_PORT:-8091}"
APPSEC_PORT="${APPSEC_PORT:-8092}" APPSEC_PORT="${APPSEC_PORT:-8092}"
REDIS_PORT="${REDIS_PORT:-8093}" REDIS_PORT="${REDIS_PORT:-8093}"
REDIS_READ_PORT="${REDIS_READ_PORT:-8094}"
LAPI_KEY="${LAPI_KEY:-e2e-mock-key}" LAPI_KEY="${LAPI_KEY:-e2e-mock-key}"
MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -189,6 +190,7 @@ start_stack() {
-e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \ -e "s|@@APPSEC_HOST@@|127.0.0.1:${APPSEC_PORT}|g" \
-e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \ -e "s|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_PORT}|g" \
-e "s|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \ -e "s|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \
-e "s|@@REDIS_READ_HOST@@|127.0.0.1:${REDIS_READ_PORT}|g" \
-e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \ -e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \
"$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml" "$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml"
@@ -206,6 +208,7 @@ start_stack() {
--backend-addr "127.0.0.1:${BACKEND_PORT}" \ --backend-addr "127.0.0.1:${BACKEND_PORT}" \
--appsec-addr "127.0.0.1:${APPSEC_PORT}" \ --appsec-addr "127.0.0.1:${APPSEC_PORT}" \
--redis-addr "127.0.0.1:${REDIS_PORT}" \ --redis-addr "127.0.0.1:${REDIS_PORT}" \
--redis-read-addr "127.0.0.1:${REDIS_READ_PORT}" \
"${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 & "${mock_tls_args[@]}" >"$WORKDIR/mock.log" 2>&1 &
MOCK_PID=$! MOCK_PID=$!
+19 -12
View File
@@ -51,16 +51,19 @@ func list(m map[string]Decision) []Decision {
// --- Redis mock (inline-command wire format, as spoken by simpleredis) --- // --- Redis mock (inline-command wire format, as spoken by simpleredis) ---
// serveRedis is a hardcoded stand-in. Every line is scanned for known IPs: // serveRedis is a hardcoded stand-in. When verdicts is true it plays a replica
// 1.2.3.4 → "f", 1.2.3.5 → "t", GET for anything else → miss ($-1). // that holds decisions: every line is scanned for known IPs, 1.2.3.4 → "f"
// (clean), 1.2.3.5 → "t" (banned); any other GET is a miss ($-1). When verdicts
// is false it plays the primary and answers every GET with a miss, so a
// scenario can prove reads are served from the replica and not the primary.
// SET, DEL, AUTH, SELECT get +OK (they don't read the response anyway). // SET, DEL, AUTH, SELECT get +OK (they don't read the response anyway).
func serveRedis(addr string) { func serveRedis(addr string, verdicts bool) {
ln, err := net.Listen("tcp", addr) ln, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
defer ln.Close() defer ln.Close()
log.Printf("mocklapi: Redis mock listening on %s", addr) log.Printf("mocklapi: Redis mock listening on %s (verdicts:%v)", addr, verdicts)
for { for {
conn, err := ln.Accept() conn, err := ln.Accept()
@@ -77,9 +80,9 @@ func serveRedis(addr string) {
} }
s := string(line) s := string(line)
switch { switch {
case strings.Contains(s, "1.2.3.4"): case verdicts && strings.Contains(s, "1.2.3.4"):
conn.Write([]byte("$1\r\nf\r\n")) conn.Write([]byte("$1\r\nf\r\n"))
case strings.Contains(s, "1.2.3.5"): case verdicts && strings.Contains(s, "1.2.3.5"):
conn.Write([]byte("$1\r\nt\r\n")) conn.Write([]byte("$1\r\nt\r\n"))
case strings.HasPrefix(strings.ToUpper(s), "GET "): case strings.HasPrefix(strings.ToUpper(s), "GET "):
conn.Write([]byte("$-1\r\n")) conn.Write([]byte("$-1\r\n"))
@@ -98,9 +101,12 @@ func main() {
backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service") backendAddr := flag.String("backend-addr", "127.0.0.1:8091", "address for the stub upstream service")
// AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine. // AppSec WAF stand-in (the real engine listens on :7422). Not a CRS engine.
appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock") appsecAddr := flag.String("appsec-addr", "127.0.0.1:8092", "address for the AppSec mock")
// Redis stand-in: the mock always serves a hardcoded GET on a plain TCP // Redis stand-ins on plain TCP ports, enough to exercise the plugin's redis
// port, enough to exercise the plugin's redis cache path. // cache path. The primary answers every GET with a miss; the replica serves
redisAddr := flag.String("redis-addr", "127.0.0.1:8093", "address for the Redis mock") // the hardcoded verdicts, so a scenario pointing redisCacheReadHosts at the
// replica proves reads are offloaded to replicas.
redisAddr := flag.String("redis-addr", "127.0.0.1:8093", "address for the Redis primary mock (writes; GET always misses)")
redisReadAddr := flag.String("redis-read-addr", "127.0.0.1:8094", "address for the Redis replica mock (serves cached verdicts)")
// Optional TLS for the LAPI: when both are set the LAPI is served over HTTPS // Optional TLS for the LAPI: when both are set the LAPI is served over HTTPS
// (cert signed by the scenario's throwaway CA) so the suite can exercise the // (cert signed by the scenario's throwaway CA) so the suite can exercise the
// bouncer's system-trust-store path. Backend and AppSec stay plaintext. // bouncer's system-trust-store path. Backend and AppSec stay plaintext.
@@ -144,7 +150,8 @@ func main() {
}))) })))
}() }()
go serveRedis(*redisAddr) go serveRedis(*redisAddr, false)
go serveRedis(*redisReadAddr, true)
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -204,9 +211,9 @@ func main() {
}) })
if *lapiTLSCert != "" && *lapiTLSKey != "" { if *lapiTLSCert != "" && *lapiTLSKey != "" {
log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s, redis on %s", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr) log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux)) log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux))
} }
log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s, redis on %s", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr) log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s, redis on %s (read %s)", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr, *redisReadAddr)
log.Fatal(http.ListenAndServe(*lapiAddr, mux)) log.Fatal(http.ListenAndServe(*lapiAddr, mux))
} }
@@ -23,5 +23,8 @@ http:
crowdsecLapiKey: "@@APIKEY@@" crowdsecLapiKey: "@@APIKEY@@"
redisCacheEnabled: "true" redisCacheEnabled: "true"
redisCacheHost: "@@REDIS_HOST@@" redisCacheHost: "@@REDIS_HOST@@"
redisCacheReadHosts:
- "@@REDIS_READ_HOST@@"
- "@@REDIS_READ_HOST@@"
forwardedHeadersTrustedIps: forwardedHeadersTrustedIps:
- "127.0.0.1/32" - "127.0.0.1/32"
+7 -4
View File
@@ -7,10 +7,13 @@ source "$HERE/../../lib/common.sh"
SCENARIO=redis SCENARIO=redis
# Redis cache check: the mock returns "f" (not banned) for 1.2.3.4 and "t" # Redis cache check: reads are offloaded to a replica (redisCacheReadHosts)
# (banned) for 1.2.3.5. All other IPs return a miss, which falls through to the # while writes go to the primary (redisCacheHost). The replica mock returns "f"
# LAPI (no decision → allowed). This proves the plugin reads cached decisions # (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5; the primary mock always
# from Redis correctly. # misses. All other IPs miss on the replica and fall through to the LAPI (no
# decision → allowed). Because the verdicts live only on the replica, the banned
# IP being blocked proves the plugin reads decisions from the replica, not the
# primary.
body() { body() {
echo "[$SCENARIO] cached clean IP must pass" echo "[$SCENARIO] cached clean IP must pass"
assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4" assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.4"