From 5a1a7998483bd67fc6df6c65d87ac2a54c50da69 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sun, 26 Jul 2026 18:14:03 +0800 Subject: [PATCH] tests/e2e: add HTTP response cache e2e tests --- tests/e2e/http_cache_test.go | 254 ++++++++++++++++++ tests/e2e/scripts/http_cache_backend.py | 20 ++ .../scripts/http_cache_servestale_backend.py | 46 ++++ .../e2e/testdata/http_cache/server_cache.yaml | 22 ++ .../http_cache/server_cache_servestale.yaml | 23 ++ .../testdata/http_cache/server_nocache.yaml | 14 + 6 files changed, 379 insertions(+) create mode 100644 tests/e2e/http_cache_test.go create mode 100644 tests/e2e/scripts/http_cache_backend.py create mode 100644 tests/e2e/scripts/http_cache_servestale_backend.py create mode 100644 tests/e2e/testdata/http_cache/server_cache.yaml create mode 100644 tests/e2e/testdata/http_cache/server_cache_servestale.yaml create mode 100644 tests/e2e/testdata/http_cache/server_nocache.yaml diff --git a/tests/e2e/http_cache_test.go b/tests/e2e/http_cache_test.go new file mode 100644 index 0000000..fb04d42 --- /dev/null +++ b/tests/e2e/http_cache_test.go @@ -0,0 +1,254 @@ +package e2e + +import ( + "context" + "io" + "strings" + "testing" + "time" + + "github.com/moby/moby/client" + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +type HTTPCacheSuite struct { + suite.Suite + ctx context.Context + beC testcontainers.Container + staleC testcontainers.Container +} + +func (s *HTTPCacheSuite) SetupSuite() { + s.ctx = context.Background() + + s.T().Log("start http cache backend container...") + beC, err := RunCacheBackendContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + s.beC = beC + + s.T().Log("start http cache serve-stale backend container...") + staleC, err := RunServeStaleBackendContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + s.staleC = staleC +} + +func (s *HTTPCacheSuite) TearDownSuite() { + if s.beC != nil { + s.beC.Terminate(s.ctx) + } + if s.staleC != nil { + s.staleC.Terminate(s.ctx) + } +} + +// TestHTTPCacheHit verifies that the HTTP response cache returns the cached +// response on repeat requests. The test backend returns a monotonically +// increasing counter. With cache enabled, every request returns the same +// counter value (the cached response from the first request). +func (s *HTTPCacheSuite) TestHTTPCacheHit() { + gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, + "testdata/http_cache/server_cache.yaml", "8080/tcp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"} + + // First request — cache miss, backend returns "cache-test-1" + code, out, err := gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body1, _ := io.ReadAll(out) + if code != 0 || !strings.Contains(string(body1), "cache-test-") { + DumpLogs(s.T(), s.ctx, "cache-proxy logs (first req)", gostC) + } + s.Require().Equal(0, code, "first request should succeed") + s.Require().Contains(string(body1), "cache-test-1", + "first request should get backend response #1") + + // Second request — cache hit, same response + code, out, err = gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body2, _ := io.ReadAll(out) + if code != 0 { + DumpLogs(s.T(), s.ctx, "cache-proxy logs (second req)", gostC) + } + s.Require().Equal(0, code, "second request should succeed") + s.Require().Equal(string(body1), string(body2), + "cached response should equal first response") + + // Third request — cache hit, same + code, out, err = gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body3, _ := io.ReadAll(out) + if code != 0 { + DumpLogs(s.T(), s.ctx, "cache-proxy logs (third req)", gostC) + } + s.Require().Equal(0, code, "third request should succeed") + s.Require().Equal(string(body1), string(body3), + "cached response should be consistent") +} + +// TestHTTPCacheDisabled verifies that without a named cache, every request +// reaches the backend and gets a fresh response (monotonically increasing +// counter). This is the control test: it proves the counting backend works +// and that the cache test actually validates caching, not incidental +// idempotency of the backend. +func (s *HTTPCacheSuite) TestHTTPCacheDisabled() { + gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, + "testdata/http_cache/server_nocache.yaml", "8080/tcp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"} + + code, out, err := gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body1, _ := io.ReadAll(out) + if code != 0 || !strings.Contains(string(body1), "cache-test-") { + DumpLogs(s.T(), s.ctx, "no-cache proxy logs (first req)", gostC) + } + s.Require().Equal(0, code) + + code, out, err = gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body2, _ := io.ReadAll(out) + if code != 0 { + DumpLogs(s.T(), s.ctx, "no-cache proxy logs (second req)", gostC) + } + s.Require().Equal(0, code) + + // Without a named cache, each request hits the backend directly. + // The backend increments a counter per request, so consecutive + // requests return different bodies. + s.Require().NotEqual(string(body1), string(body2), + "without cache, consecutive requests should return different responses") +} + +// TestHTTPCacheServeStale verifies that a stale (expired) cached response is +// served when the upstream fetch fails and cache.serveStale is enabled. +// +// The serve-stale backend serves one HTTP request, then shuts down its HTTP +// server and switches to accepting TCP connections and immediately closing +// them. This makes the upstream dial succeed but the HTTP response read fail, +// which triggers the serve-stale path. +// +// Steps: +// 1. First request: cache miss, backend responds → cache stores the response +// with TTL 3s. The backend then switches to connection-dropping mode. +// 2. Wait 4s for the cache entry to expire. +// 3. Second request: cache hit (stale), dial succeeds (TCP acceptor accepts), +// but no HTTP response arrives → http.ReadResponse fails → serveStale +// returns the cached "cache-test-1". +func (s *HTTPCacheSuite) TestHTTPCacheServeStale() { + gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, + "testdata/http_cache/server_cache_servestale.yaml", "8080/tcp") + s.Require().NoError(err) + defer gostC.Terminate(s.ctx) + + cmd := []string{"curl", "-v", "-s", "http://127.0.0.1:8080/"} + + // First request — cache miss, backend returns "cache-test-1" + code, out, err := gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body1, _ := io.ReadAll(out) + if code != 0 || !strings.Contains(string(body1), "cache-test-") { + DumpLogs(s.T(), s.ctx, "serve-stale logs (first req)", gostC) + } + s.Require().Equal(0, code, "first request should succeed") + s.Require().Contains(string(body1), "cache-test-1", + "first request should get backend response #1") + + // At this point the backend has shut down its HTTP server and switched to + // connection-dropping mode. Wait for cache TTL (3s) to expire. + time.Sleep(4 * time.Second) + + // Second request — stale cache hit, upstream connects but yields no + // response → serve-stale returns the expired cached response. + code, out, err = gostC.Exec(s.ctx, cmd) + s.Require().NoError(err) + body2, _ := io.ReadAll(out) + if code != 0 || !strings.Contains(string(body2), "cache-test-") { + DumpLogs(s.T(), s.ctx, "serve-stale logs (second req)", gostC) + } + s.Require().Equal(0, code, "serve-stale should return the cached response") + s.Require().Equal(string(body1), string(body2), + "serve-stale should return the expired cached response") +} + +func TestHTTPCacheSuite(t *testing.T) { + suite.Run(t, new(HTTPCacheSuite)) +} + +// RunCacheBackendContainer starts a container running the cache test HTTP server +// that returns a monotonically increasing counter in each response body. +func RunCacheBackendContainer(ctx context.Context, networkName string) (testcontainers.Container, error) { + req := cacheBackendContainerRequest(ctx, networkName) + return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) +} + +func cacheBackendContainerRequest(_ context.Context, networkName string) testcontainers.ContainerRequest { + return testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: ".", + Dockerfile: "Dockerfile", + Repo: "gost-e2e", + Tag: "latest", + KeepImage: true, + BuildOptionsModifier: func(opts *client.ImageBuildOptions) { + opts.NetworkMode = "host" + }, + }, + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {"cache-backend"}, + }, + Files: []testcontainers.ContainerFile{ + {HostFilePath: "scripts/http_cache_backend.py", ContainerFilePath: "/scripts/http_cache_backend.py", FileMode: 0644}, + }, + ExposedPorts: []string{"5677/tcp"}, + Cmd: []string{"python3", "/scripts/http_cache_backend.py"}, + WaitingFor: wait.ForExposedPort(), + } +} + +// RunServeStaleBackendContainer starts a container running the serve-stale test +// HTTP server. It serves one request with a counter body, then replaces itself +// with a raw TCP socket that accepts connections and immediately closes them. +// This makes upstream dials succeed but HTTP response reads fail, which +// triggers the serve-stale path in the sniffer. +func RunServeStaleBackendContainer(ctx context.Context, networkName string) (testcontainers.Container, error) { + req := serveStaleBackendContainerRequest(ctx, networkName) + return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) +} + +func serveStaleBackendContainerRequest(_ context.Context, networkName string) testcontainers.ContainerRequest { + return testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: ".", + Dockerfile: "Dockerfile", + Repo: "gost-e2e", + Tag: "latest", + KeepImage: true, + BuildOptionsModifier: func(opts *client.ImageBuildOptions) { + opts.NetworkMode = "host" + }, + }, + Networks: []string{networkName}, + NetworkAliases: map[string][]string{ + networkName: {"cache-servestale"}, + }, + Files: []testcontainers.ContainerFile{ + {HostFilePath: "scripts/http_cache_servestale_backend.py", ContainerFilePath: "/scripts/http_cache_servestale_backend.py", FileMode: 0644}, + }, + ExposedPorts: []string{"5676/tcp"}, + Cmd: []string{"python3", "/scripts/http_cache_servestale_backend.py"}, + WaitingFor: wait.ForExposedPort(), + } +} diff --git a/tests/e2e/scripts/http_cache_backend.py b/tests/e2e/scripts/http_cache_backend.py new file mode 100644 index 0000000..b8eecea --- /dev/null +++ b/tests/e2e/scripts/http_cache_backend.py @@ -0,0 +1,20 @@ +from http.server import BaseHTTPRequestHandler, HTTPServer + +_counter = 0 + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + global _counter + _counter += 1 + body = b"cache-test-%d" % _counter + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + +HTTPServer(("0.0.0.0", 5677), Handler).serve_forever() diff --git a/tests/e2e/scripts/http_cache_servestale_backend.py b/tests/e2e/scripts/http_cache_servestale_backend.py new file mode 100644 index 0000000..d44f091 --- /dev/null +++ b/tests/e2e/scripts/http_cache_servestale_backend.py @@ -0,0 +1,46 @@ +from http.server import BaseHTTPRequestHandler, HTTPServer +import socket +import threading + +_counter = 0 +_http_server = None + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + global _counter + _counter += 1 + body = b"cache-test-%d" % _counter + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + threading.Thread(target=stop_server, daemon=True).start() + + def log_message(self, format, *args): + return + + +def stop_server(): + global _http_server + if _http_server: + _http_server.shutdown() + _http_server.server_close() + + +def start_raw_acceptor(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", 5676)) + sock.listen(5) + while True: + conn, addr = sock.accept() + conn.close() + + +_http_server = HTTPServer(("0.0.0.0", 5676), Handler) +_http_server.serve_forever() +_http_server.server_close() + +start_raw_acceptor() diff --git a/tests/e2e/testdata/http_cache/server_cache.yaml b/tests/e2e/testdata/http_cache/server_cache.yaml new file mode 100644 index 0000000..c3d73df --- /dev/null +++ b/tests/e2e/testdata/http_cache/server_cache.yaml @@ -0,0 +1,22 @@ +caches: +- name: http-cache + memory: + ttl: 10m + maxSize: 1000 + +services: +- name: cache-proxy + addr: :8080 + cache: http-cache + handler: + type: tcp + metadata: + sniffing: true + sniffing.timeout: 2s + cache.ttl: 60s + listener: + type: tcp + forwarder: + nodes: + - name: target + addr: cache-backend:5677 diff --git a/tests/e2e/testdata/http_cache/server_cache_servestale.yaml b/tests/e2e/testdata/http_cache/server_cache_servestale.yaml new file mode 100644 index 0000000..891f943 --- /dev/null +++ b/tests/e2e/testdata/http_cache/server_cache_servestale.yaml @@ -0,0 +1,23 @@ +caches: +- name: http-cache + memory: + ttl: 10m + maxSize: 1000 + +services: +- name: cache-proxy + addr: :8080 + cache: http-cache + handler: + type: tcp + metadata: + sniffing: true + sniffing.timeout: 2s + cache.ttl: 3s + cache.serveStale: true + listener: + type: tcp + forwarder: + nodes: + - name: target + addr: cache-servestale:5676 diff --git a/tests/e2e/testdata/http_cache/server_nocache.yaml b/tests/e2e/testdata/http_cache/server_nocache.yaml new file mode 100644 index 0000000..4c93e52 --- /dev/null +++ b/tests/e2e/testdata/http_cache/server_nocache.yaml @@ -0,0 +1,14 @@ +services: +- name: cache-proxy + addr: :8080 + handler: + type: tcp + metadata: + sniffing: true + sniffing.timeout: 2s + listener: + type: tcp + forwarder: + nodes: + - name: target + addr: cache-backend:5677