From 96beb9192ea2e44760fddb88dfee2b4f7ae3744a Mon Sep 17 00:00:00 2001 From: mhx Date: Sat, 25 Jul 2026 21:21:20 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test(e2e/redis):=20exercise=20read-?= =?UTF-8?q?from-replica=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- tests/e2e/mock/lib/common.sh | 3 +++ tests/e2e/mock/mocklapi/main.go | 31 +++++++++++++--------- tests/e2e/mock/scenarios/redis/dynamic.yml | 3 +++ tests/e2e/mock/scenarios/redis/run.sh | 11 +++++--- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/tests/e2e/mock/lib/common.sh b/tests/e2e/mock/lib/common.sh index 30d88f8..d5cdaab 100644 --- a/tests/e2e/mock/lib/common.sh +++ b/tests/e2e/mock/lib/common.sh @@ -21,6 +21,7 @@ LAPI_PORT="${LAPI_PORT:-8090}" BACKEND_PORT="${BACKEND_PORT:-8091}" APPSEC_PORT="${APPSEC_PORT:-8092}" REDIS_PORT="${REDIS_PORT:-8093}" +REDIS_READ_PORT="${REDIS_READ_PORT:-8094}" LAPI_KEY="${LAPI_KEY:-e2e-mock-key}" 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|@@BACKEND_URL@@|http://127.0.0.1:${BACKEND_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" \ "$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml" @@ -206,6 +208,7 @@ start_stack() { --backend-addr "127.0.0.1:${BACKEND_PORT}" \ --appsec-addr "127.0.0.1:${APPSEC_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_PID=$! diff --git a/tests/e2e/mock/mocklapi/main.go b/tests/e2e/mock/mocklapi/main.go index 171725f..ba59b93 100644 --- a/tests/e2e/mock/mocklapi/main.go +++ b/tests/e2e/mock/mocklapi/main.go @@ -51,16 +51,19 @@ func list(m map[string]Decision) []Decision { // --- Redis mock (inline-command wire format, as spoken by simpleredis) --- -// serveRedis is a hardcoded stand-in. Every line is scanned for known IPs: -// 1.2.3.4 → "f", 1.2.3.5 → "t", GET for anything else → miss ($-1). +// serveRedis is a hardcoded stand-in. When verdicts is true it plays a replica +// 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). -func serveRedis(addr string) { +func serveRedis(addr string, verdicts bool) { ln, err := net.Listen("tcp", addr) if err != nil { log.Fatal(err) } 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 { conn, err := ln.Accept() @@ -77,9 +80,9 @@ func serveRedis(addr string) { } s := string(line) 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")) - 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")) case strings.HasPrefix(strings.ToUpper(s), "GET "): 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") // 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") - // Redis stand-in: the mock always serves a hardcoded GET on a plain TCP - // port, enough to exercise the plugin's redis cache path. - redisAddr := flag.String("redis-addr", "127.0.0.1:8093", "address for the Redis mock") + // Redis stand-ins on plain TCP ports, enough to exercise the plugin's redis + // cache path. The primary answers every GET with a miss; the replica serves + // 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 // (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. @@ -144,7 +150,8 @@ func main() { }))) }() - go serveRedis(*redisAddr) + go serveRedis(*redisAddr, false) + go serveRedis(*redisReadAddr, true) mux := http.NewServeMux() @@ -204,9 +211,9 @@ func main() { }) 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.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)) } diff --git a/tests/e2e/mock/scenarios/redis/dynamic.yml b/tests/e2e/mock/scenarios/redis/dynamic.yml index db28301..6f91081 100644 --- a/tests/e2e/mock/scenarios/redis/dynamic.yml +++ b/tests/e2e/mock/scenarios/redis/dynamic.yml @@ -23,5 +23,8 @@ http: crowdsecLapiKey: "@@APIKEY@@" redisCacheEnabled: "true" redisCacheHost: "@@REDIS_HOST@@" + redisCacheReadHosts: + - "@@REDIS_READ_HOST@@" + - "@@REDIS_READ_HOST@@" forwardedHeadersTrustedIps: - "127.0.0.1/32" diff --git a/tests/e2e/mock/scenarios/redis/run.sh b/tests/e2e/mock/scenarios/redis/run.sh index a4c56eb..a08e751 100644 --- a/tests/e2e/mock/scenarios/redis/run.sh +++ b/tests/e2e/mock/scenarios/redis/run.sh @@ -7,10 +7,13 @@ source "$HERE/../../lib/common.sh" SCENARIO=redis -# Redis cache check: the mock returns "f" (not banned) for 1.2.3.4 and "t" -# (banned) for 1.2.3.5. All other IPs return a miss, which falls through to the -# LAPI (no decision → allowed). This proves the plugin reads cached decisions -# from Redis correctly. +# Redis cache check: reads are offloaded to a replica (redisCacheReadHosts) +# while writes go to the primary (redisCacheHost). The replica mock returns "f" +# (not banned) for 1.2.3.4 and "t" (banned) for 1.2.3.5; the primary mock always +# 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() { 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"