From 1e0dadedb23eb204dd3809952edaa9e377f65a53 Mon Sep 17 00:00:00 2001 From: maxlerebourg Date: Sat, 25 Jul 2026 16:30:36 +0200 Subject: [PATCH] :sparkles: add testing for redis with mock --- Makefile | 2 +- tests/e2e/mock/lib/common.sh | 3 ++ tests/e2e/mock/mocklapi/main.go | 56 ++++++++++++++++++++-- tests/e2e/mock/scenarios/redis/dynamic.yml | 27 +++++++++++ tests/e2e/mock/scenarios/redis/run.sh | 25 ++++++++++ 5 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/mock/scenarios/redis/dynamic.yml create mode 100644 tests/e2e/mock/scenarios/redis/run.sh diff --git a/Makefile b/Makefile index 11a2f61..fce766f 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ export GO111MODULE=on # Binary/mock suite (Traefik binary + mock LAPI). This is what CI runs. # The local Docker suite (make e2e) lives in a separate PR/branch. -E2E_MOCK_SCENARIOS := stream-mode live-mode none-mode trusted-ips custom-ban-page captcha appsec tls-system-ca +E2E_MOCK_SCENARIOS := $(notdir $(wildcard tests/e2e/mock/scenarios/*)) default: lint test diff --git a/tests/e2e/mock/lib/common.sh b/tests/e2e/mock/lib/common.sh index 66ad627..30d88f8 100644 --- a/tests/e2e/mock/lib/common.sh +++ b/tests/e2e/mock/lib/common.sh @@ -20,6 +20,7 @@ WEB_PORT="${WEB_PORT:-8000}" LAPI_PORT="${LAPI_PORT:-8090}" BACKEND_PORT="${BACKEND_PORT:-8091}" APPSEC_PORT="${APPSEC_PORT:-8092}" +REDIS_PORT="${REDIS_PORT:-8093}" LAPI_KEY="${LAPI_KEY:-e2e-mock-key}" MOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -187,6 +188,7 @@ start_stack() { -e "s|@@LAPI_HOST@@|127.0.0.1:${LAPI_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|@@REDIS_HOST@@|127.0.0.1:${REDIS_PORT}|g" \ -e "s|@@SCENARIO_DIR@@|${scenario_dir}|g" \ "$scenario_dir/dynamic.yml" > "$WORKDIR/dynamic.yml" @@ -203,6 +205,7 @@ start_stack() { --lapi-addr "127.0.0.1:${LAPI_PORT}" \ --backend-addr "127.0.0.1:${BACKEND_PORT}" \ --appsec-addr "127.0.0.1:${APPSEC_PORT}" \ + --redis-addr "127.0.0.1:${REDIS_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 cb0ba11..320a5df 100644 --- a/tests/e2e/mock/mocklapi/main.go +++ b/tests/e2e/mock/mocklapi/main.go @@ -2,7 +2,8 @@ // suite. It answers only the few LAPI routes the plugin calls — live/none // decision lookups, the stream poll and the usage-metrics push — and lets the // test drive decisions through /admin instead of `cscli`. It also serves the -// stub upstream that Traefik proxies allowed requests to. +// stub upstream that Traefik proxies allowed requests to, and a hardcoded Redis +// stand-in for exercising the redis cache path. // // It is NOT a Crowdsec/AppSec conformance harness — the real WAF engine (OWASP // CRS, virtual patching) is out of scope. The AppSec endpoint here emulates a @@ -11,10 +12,12 @@ package main import ( + "bufio" "encoding/json" "flag" "io" "log" + "net" "net/http" "strings" "sync" @@ -46,6 +49,48 @@ func list(m map[string]Decision) []Decision { return out } +// --- 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 → "t", 1.2.3.5 → "f", GET for anything else → miss ($-1). +// SET, DEL, AUTH, SELECT get +OK (they don't read the response anyway). +func serveRedis(addr string) { + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Fatal(err) + } + defer ln.Close() + log.Printf("mocklapi: Redis mock listening on %s", addr) + + for { + conn, err := ln.Accept() + if err != nil { + continue + } + go func(conn net.Conn) { + defer conn.Close() + rd := bufio.NewReader(conn) + for { + line, _, err := rd.ReadLine() + if err != nil { + return + } + s := string(line) + switch { + case strings.Contains(s, "1.2.3.4"): + conn.Write([]byte("$1\r\nf\r\n")) + case 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")) + default: + conn.Write([]byte("+OK\r\n")) + } + } + }(conn) + } +} + func main() { lapiAddr := flag.String("lapi-addr", "127.0.0.1:8090", "address for the LAPI mock") // The stub upstream Traefik proxies allowed requests to — the binary-suite @@ -53,6 +98,9 @@ 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") // 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. @@ -96,6 +144,8 @@ func main() { }))) }() + go serveRedis(*redisAddr) + mux := http.NewServeMux() // Readiness probe for the test harness (empty body, 200). @@ -154,9 +204,9 @@ func main() { }) if *lapiTLSCert != "" && *lapiTLSKey != "" { - log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr) + log.Printf("mocklapi: LAPI on %s (TLS), backend on %s, appsec on %s, redis on %s", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr) log.Fatal(http.ListenAndServeTLS(*lapiAddr, *lapiTLSCert, *lapiTLSKey, mux)) } - log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s", *lapiAddr, *backendAddr, *appsecAddr) + log.Printf("mocklapi: LAPI on %s, backend on %s, appsec on %s, redis on %s", *lapiAddr, *backendAddr, *appsecAddr, *redisAddr) log.Fatal(http.ListenAndServe(*lapiAddr, mux)) } diff --git a/tests/e2e/mock/scenarios/redis/dynamic.yml b/tests/e2e/mock/scenarios/redis/dynamic.yml new file mode 100644 index 0000000..db28301 --- /dev/null +++ b/tests/e2e/mock/scenarios/redis/dynamic.yml @@ -0,0 +1,27 @@ +http: + routers: + r: + rule: "PathPrefix(`/foo`)" + entryPoints: + - web + service: backend + middlewares: + - bouncer + services: + backend: + loadBalancer: + servers: + - url: "@@BACKEND_URL@@" + middlewares: + bouncer: + plugin: + bouncer: + enabled: "true" + crowdsecMode: live + crowdsecLapiScheme: http + crowdsecLapiHost: "@@LAPI_HOST@@" + crowdsecLapiKey: "@@APIKEY@@" + redisCacheEnabled: "true" + redisCacheHost: "@@REDIS_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 new file mode 100644 index 0000000..b1f5806 --- /dev/null +++ b/tests/e2e/mock/scenarios/redis/run.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../../lib/common.sh +source "$HERE/../../lib/common.sh" + +SCENARIO=redis + +# Redis cache check: the mock returns "t" (banned) for 1.2.3.4 and "f" (not +# 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. +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" + + echo "[$SCENARIO] cached banned IP must be blocked" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 403 -H "X-Forwarded-For: 1.2.3.5" + + echo "[$SCENARIO] unknown IP (redis miss) must fall through to LAPI and pass" + assert_status "http://127.0.0.1:${WEB_PORT}/foo" 200 -H "X-Forwarded-For: 1.2.3.6" +} + +run_scenario "$SCENARIO" "$HERE" body