Files
redapricot/e2e/e2e_test.go
T
2026-08-15 17:31:35 +08:00

311 lines
9.7 KiB
Go

package e2e
import (
"context"
"fmt"
"io"
"net"
"testing"
"time"
"github.com/iceBear67/redapricot/client"
)
// startClient builds and starts an in-process client against the hub, with the
// given mappings, returning the running client.
func startClient(t *testing.T, hubAddr, psk string, maxConn int, mappings []client.Mapping) *client.Client {
t.Helper()
return startClientWithPing(t, hubAddr, psk, maxConn, 20000, mappings)
}
// startClientWithPing is startClient with an explicit heartbeat interval, for
// tests that need liveness detection to trigger quickly.
func startClientWithPing(t *testing.T, hubAddr, psk string, maxConn, pingMs int, mappings []client.Mapping) *client.Client {
t.Helper()
return startClientCfg(t, &client.Config{
Server: hubAddr,
PSK: psk,
MaxConn: maxConn,
PingIntervalMs: pingMs,
Mappings: mappings,
})
}
// startClientCfg runs an in-process client from a fully-specified config, for
// tests that need a knob the shorthand helpers do not expose.
func startClientCfg(t *testing.T, cfg *client.Config) *client.Client {
t.Helper()
c := client.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
c.Close()
})
if err := c.Start(ctx); err != nil {
t.Fatalf("client start: %v", err)
}
// Give the control-session registration a moment to reach the hub.
time.Sleep(200 * time.Millisecond)
return c
}
// TestRoundTrip verifies the full path plus verbatim handshake forwarding and
// case-insensitive pattern matching.
func TestRoundTrip(t *testing.T) {
const psk = "e2e-roundtrip"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 4, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
// The player connects using mixed case; the hub matches case-insensitively
// but forwards the address verbatim.
pc := dialPlayer(t, hubAddr, "MC.Local")
defer pc.Close()
playerEcho(t, pc, []byte("hello redapricot"))
ev := dest.waitEvent(t, 5*time.Second)
if ev.handshakeAddr != "MC.Local" {
t.Fatalf("destination saw handshake address %q, want verbatim %q", ev.handshakeAddr, "MC.Local")
}
if ev.hasProxy {
t.Fatalf("did not expect a PROXY header for a non-proxy mapping")
}
}
// TestRegexPatternMatch verifies a wildcard regex pattern routes a matching
// player (whole-hostname, case-insensitive) and drops a non-matching one, and
// that the client maps the hub-echoed pattern back to its destination.
func TestRegexPatternMatch(t *testing.T) {
const psk = "e2e-regex"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: `mc\d+\.local`, Destination: dest.addr},
})
// Matches the regex (digit wildcard, mixed case); the whole hostname matches.
pc := dialPlayer(t, hubAddr, "MC7.Local")
defer pc.Close()
playerEcho(t, pc, []byte("regex hello"))
ev := dest.waitEvent(t, 5*time.Second)
if ev.handshakeAddr != "MC7.Local" {
t.Fatalf("destination saw handshake address %q, want verbatim %q", ev.handshakeAddr, "MC7.Local")
}
// A host that does not fully match the pattern is dropped ('\d+' needs a digit).
pc2 := dialPlayer(t, hubAddr, "mc.local")
defer pc2.Close()
_ = pc2.SetReadDeadline(time.Now().Add(3 * time.Second))
if _, err := pc2.Read(make([]byte, 16)); err == nil {
t.Fatalf("expected the hub to drop a host that does not match the regex")
}
}
// TestLargeTransfer pushes a multi-megabyte payload both ways to exercise mux
// framing and back-pressure.
func TestLargeTransfer(t *testing.T) {
const psk = "e2e-large"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
payload := make([]byte, 3*1024*1024)
for i := range payload {
payload[i] = byte(i*31 + 7)
}
// Write from a goroutine while reading back concurrently to avoid deadlock.
writeErr := make(chan error, 1)
go func() {
_, err := pc.Write(payload)
writeErr <- err
}()
got := make([]byte, len(payload))
_ = pc.SetReadDeadline(time.Now().Add(30 * time.Second))
if _, err := io.ReadFull(pc, got); err != nil {
t.Fatalf("read large echo: %v", err)
}
if err := <-writeErr; err != nil {
t.Fatalf("write large payload: %v", err)
}
for i := range payload {
if got[i] != payload[i] {
t.Fatalf("large echo mismatch at byte %d", i)
}
}
}
// TestConcurrentStreamsUseMultipleConns confirms the least-loaded allocator
// grows the pool breadth-first: concurrent streams spread over several worker
// connections rather than stacking on one, without ever exceeding maxConn.
func TestConcurrentStreamsUseMultipleConns(t *testing.T) {
const psk = "e2e-concurrent"
const n = 20
const maxConn = 4
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
c := startClient(t, hubAddr, psk, maxConn, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
conns := make([]net.Conn, 0, n)
defer func() {
for _, pc := range conns {
_ = pc.Close()
}
}()
// Establish streams sequentially so allocation is deterministic; keep them
// all open to hold streams active.
for i := 0; i < n; i++ {
pc := dialPlayer(t, hubAddr, "mc.local")
playerEcho(t, pc, []byte(fmt.Sprintf("hello-%d", i)))
conns = append(conns, pc)
}
got := c.WorkerConnCount()
if got < 2 {
t.Fatalf("expected >=2 worker conns for %d concurrent streams, got %d", n, got)
}
if got > maxConn {
t.Fatalf("worker conns %d exceed maxConn %d", got, maxConn)
}
t.Logf("%d concurrent streams spread over %d worker conn(s)", n, got)
}
// TestProxyProtocol checks that the client prepends a correct HAProxy v2 header
// carrying the player's real source address.
func TestProxyProtocol(t *testing.T) {
const psk = "e2e-proxy"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr, ProxyProtocol: true},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
playerEcho(t, pc, []byte("proxied hello"))
ev := dest.waitEvent(t, 5*time.Second)
if !ev.hasProxy {
t.Fatalf("expected a PROXY v2 header")
}
localPort := pc.LocalAddr().(*net.TCPAddr).Port
if ev.proxy.srcPort != localPort {
t.Fatalf("proxy src port %d, want player local port %d", ev.proxy.srcPort, localPort)
}
if !ev.proxy.srcIP.IsLoopback() {
t.Fatalf("proxy src ip %v, want loopback", ev.proxy.srcIP)
}
t.Logf("PROXY v2: src=%v:%d dst=%v:%d", ev.proxy.srcIP, ev.proxy.srcPort, ev.proxy.dstIP, ev.proxy.dstPort)
}
// TestPlayerDisconnectPropagates: player closing → hub FIN → destination EOF.
func TestPlayerDisconnectPropagates(t *testing.T) {
const psk = "e2e-disc"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
playerEcho(t, pc, []byte("bye soon"))
_ = pc.Close()
select {
case <-dest.connClosed:
case <-time.After(5 * time.Second):
t.Fatalf("destination did not observe the player disconnect")
}
}
// TestDestinationDisconnectPropagates: destination closing → client FIN → player EOF.
func TestDestinationDisconnectPropagates(t *testing.T) {
const psk = "e2e-destclose"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEchoOnceClose)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
payload := []byte("one shot")
if _, err := pc.Write(payload); err != nil {
t.Fatalf("write: %v", err)
}
got := make([]byte, len(payload))
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := io.ReadFull(pc, got); err != nil {
t.Fatalf("read echo: %v", err)
}
// Destination has now closed; the player's next read must reach EOF.
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := pc.Read(make([]byte, 16)); err == nil {
t.Fatalf("expected EOF after destination closed")
}
}
// TestBadPSK: a client with the wrong PSK cannot establish a control session.
func TestBadPSK(t *testing.T) {
const psk = "e2e-correct"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
cfg := &client.Config{
Server: hubAddr,
PSK: "totally-wrong",
MaxConn: 2,
PingIntervalMs: 20000,
Mappings: []client.Mapping{{Pattern: "mc.local", Destination: dest.addr}},
}
c := client.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
defer func() { cancel(); c.Close() }()
if err := c.Start(ctx); err == nil {
t.Fatalf("expected control session to fail with a wrong PSK")
}
}
// TestUnmatchedPattern: a player using an unregistered address is dropped.
func TestUnmatchedPattern(t *testing.T) {
const psk = "e2e-nomatch"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "unknown.host")
defer pc.Close()
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := pc.Read(make([]byte, 16)); err == nil {
t.Fatalf("expected the hub to drop an unmatched player")
}
}