tests/e2e: add HTTP response cache e2e tests

This commit is contained in:
ginuerzh
2026-07-26 18:14:03 +08:00
parent 5204d6285e
commit 5a1a799848
6 changed files with 379 additions and 0 deletions
+254
View File
@@ -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(),
}
}
+20
View File
@@ -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()
@@ -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()
+22
View File
@@ -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
@@ -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
+14
View File
@@ -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