Files
gateway/base_ha_backend_test.go
zeekayandHanzo Dev 53350699b6 deps(vendor): fold the krakend/lura module set into internal/
Every import in this module now resolves to github.com/hanzoai/gateway/v2.
The 34 github.com/krakend/* + github.com/luraproject/lura modules that
supplied the engine and its components are vendored under internal/ and
their 91 packages rewritten onto our own path.

Why fold rather than mirror into 34 hanzoai/* repos:

  - It removes the exposure outright. 34 rows leave 34 rows of go.sum to
    reason about; zero modules leave nothing. `go list -m all` and go.sum
    now contain no krakend/lura entry at all.
  - Cross-references cannot go wrong. 28 of the 34 import lura (97 files
    inside lura self-reference it). Mirroring means rewriting every one of
    those to our lura, and a single miss silently links a SECOND copy of
    lura into the binary. With one module that is structurally impossible.
  - It creates no duplicates. These are lura-coupled gateway glue with no
    consumer outside this binary; publishing 34 repos would be 34 new
    things to maintain for zero second caller. internal/ makes that
    guarantee mechanical — Go forbids importing it from outside this
    module, so none of it can become a competing public package. Verified
    no repo outside gateway imports any hanzoai/gateway/v2 subpackage.

Layout mirrors provenance so an upstream re-sync stays a diff:

  luraproject/lura/v2  -> internal/lura         (the engine, 20 pkgs)
  krakend/krakend-<x>  -> internal/plugin/<x>   (28 components)
  krakend/<x>          -> internal/pkg/<x>      (6 standalone libs)

This commit is PURELY MECHANICAL: 196 of the 197 vendored files are
byte-identical to their upstream original once the import rewrite is
applied. The single exception is bloomfilter's subpackage, which upstream
names `krakend` — the one package name that could not survive as-is; it
becomes internal/pkg/bloomfilter/register. Hand edits land separately in
the next commit so they are readable on their own.

Per `go mod vendor` semantics, dependencies' _test.go files are not
vendored. They test upstream code at a pinned version that we do not
modify, several cannot run at all from a module distribution (they need
TLS certs and compiled .so plugins upstream generates but never ships),
and one is a 100k-request stress test that exhausts the ephemeral port
range and fails every package running beside it. Fold integrity is proven
by the byte-diff above instead, which is the stronger check.

Preserved deliberately — each of these is behaviour, not branding:
  - the `github.com/devopsfaith/krakend*` extra_config namespace strings
    (35 of them). These are config-surface keys, not import paths;
    renaming one silently stops that component finding its config. The
    jose validator namespace backs `auth/validator` on 212 endpoints.
  - core.KrakendVersion / KrakendUserAgent / the X-KRAKEND consts, whose
    VALUES rebrand.go overwrites — that seam is unchanged.
  - the KRAKEND_ koanf env prefix (a hardcoded upstream const).
  - every vendored module's LICENSE (34 files, Apache-2.0/MIT).

The Makefile/CI -X paths move with the engine. An -X naming a symbol that
does not exist is silently ignored by the linker, so a stale path here
would have shipped an "undefined" version in the backend User-Agent and
X-Api-Version with a perfectly green build. Verified live:
`gateway version` reports 2.13.0, not undefined.

Verification (GOWORK=off CGO_ENABLED=0, both tag modes):
  go build ./...              OK      go build -tags legacy ./...   OK
  go vet ./...                clean   go vet -tags legacy ./...     clean
  go test ./...               6/6 ok  go test -tags legacy          5/5 ok
  tests/ integration harness  47 fixtures pass
  gateway check -c configs/{hanzo,lux}/gateway.json  -> Syntax OK!
  go list -m all | grep -iE 'krakend|lura'  -> empty
  go mod verify -> all modules verified

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 14:57:19 -07:00

646 lines
19 KiB
Go

//go:build legacy
// +build legacy
package gateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/gateway/v2/internal/lura/config"
"github.com/hanzoai/gateway/v2/internal/lura/logging"
"github.com/hanzoai/gateway/v2/internal/lura/proxy"
)
// leaderStub serves /_ha/leader and optionally flips the returned writer URL.
type leaderStub struct {
mu sync.Mutex
leaderURL string
nodeID string
term uint64
leaseExp time.Time
}
func (l *leaderStub) set(url, nodeID string, term uint64, leaseExp time.Time) {
l.mu.Lock()
l.leaderURL = url
l.nodeID = nodeID
l.term = term
l.leaseExp = leaseExp
l.mu.Unlock()
}
func (l *leaderStub) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l.mu.Lock()
defer l.mu.Unlock()
if l.leaderURL == "" {
http.Error(w, "no leader", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"leader_url": l.leaderURL,
"node_id": l.nodeID,
"term": l.term,
"lease_expires": l.leaseExp,
})
})
}
// endpointStub is a fake base-ha pod; records the method+path it received
// and returns a configurable status.
type endpointStub struct {
name string
mu sync.Mutex
calls []string
status int32 // atomic
}
func (e *endpointStub) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
e.mu.Lock()
e.calls = append(e.calls, r.Method+" "+r.URL.Path)
e.mu.Unlock()
st := int(atomic.LoadInt32(&e.status))
if st == 0 {
st = http.StatusOK
}
w.Header().Set("X-Base-TxSeq", "42")
w.WriteHeader(st)
_, _ = w.Write([]byte(fmt.Sprintf(`{"pod":%q}`, e.name)))
})
}
func (e *endpointStub) setStatus(code int) { atomic.StoreInt32(&e.status, int32(code)) }
func (e *endpointStub) snapshot() []string {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]string, len(e.calls))
copy(out, e.calls)
return out
}
// --- helper --------------------------------------------------------------
func buildTestProxy(t *testing.T, dns string, port int, extra ...func(*BaseHAConfig)) (proxy.Proxy, *baseHAUpstream) {
t.Helper()
cfg := BaseHAConfig{
ServiceDNS: dns,
Port: port,
LeaderPollInterval: "100ms",
ReadYourWritesTTL: "200ms",
}
for _, f := range extra {
f(&cfg)
}
up, err := newBaseHAUpstream(cfg, logging.NoOp)
if err != nil {
t.Fatalf("newBaseHAUpstream: %v", err)
}
t.Cleanup(up.close)
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{fmt.Sprintf("http://%s:%d", dns, port)},
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": cfg.ServiceDNS,
"port": cfg.Port,
"leader_poll_interval": cfg.LeaderPollInterval,
"read_your_writes_ttl": cfg.ReadYourWritesTTL,
},
},
}
return newBaseHAProxy(remote, up), up
}
// pollUntilWriter blocks until the upstream observes a non-empty writer or
// the deadline expires. Used because refresh() is async from server start.
func pollUntilWriter(t *testing.T, up *baseHAUpstream, d time.Duration) string {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if w := up.currentWriter(); w != "" {
return w
}
up.kickRefresh()
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("writer never discovered within %s", d)
return ""
}
func newHAProxyRequest(method, path string, headers http.Header, body string) *proxy.Request {
var rc io.ReadCloser
if body != "" {
rc = io.NopCloser(strings.NewReader(body))
}
return &proxy.Request{
Method: method,
Path: path,
Query: url.Values{},
Headers: headers,
Body: rc,
}
}
// --- tests ---------------------------------------------------------------
func TestBaseHAConfigRequiredFields(t *testing.T) {
_, err := newBaseHAUpstream(BaseHAConfig{Port: 8090}, logging.NoOp)
if err == nil {
t.Fatalf("empty service_dns must error")
}
_, err = newBaseHAUpstream(BaseHAConfig{ServiceDNS: "x", Port: 0}, logging.NoOp)
if err == nil {
t.Fatalf("zero port must error")
}
_, err = newBaseHAUpstream(BaseHAConfig{ServiceDNS: "x", Port: 70000}, logging.NoOp)
if err == nil {
t.Fatalf("port > 65535 must error")
}
_, err = newBaseHAUpstream(BaseHAConfig{ServiceDNS: "x", Port: 8090, LeaderPollInterval: "10ms"}, logging.NoOp)
if err == nil {
t.Fatalf("poll interval < 100ms must error (retry-storm guard)")
}
}
// TestBaseHAWritePinsToLeader proves POST lands on the writer URL returned
// by /_ha/leader, regardless of the enclosing backend Host.
func TestBaseHAWritePinsToLeader(t *testing.T) {
writer := &endpointStub{name: "foo-0"}
reader := &endpointStub{name: "foo-1"}
writerSrv := httptest.NewServer(writer.handler())
defer writerSrv.Close()
readerSrv := httptest.NewServer(reader.handler())
defer readerSrv.Close()
leader := &leaderStub{}
leader.set(writerSrv.URL, "foo-0", 1, time.Now().Add(10*time.Second))
leaderSrv := httptest.NewServer(leader.handler())
defer leaderSrv.Close()
u, _ := url.Parse(leaderSrv.URL)
host, port := splitHostPort(u.Host)
// The read-host is deliberately the READER pod so we can tell where
// the proxy actually sent the request.
ru, _ := url.Parse(readerSrv.URL)
_, _ = host, port
p, up := buildTestProxy(t, host, port)
_ = pollUntilWriter(t, up, 2*time.Second)
// Point the enclosing Host at the reader so reads go there.
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{"http://" + ru.Host},
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": host,
"port": port,
"leader_poll_interval": "100ms",
},
},
}
p = newBaseHAProxy(remote, up)
// Write → writer.
resp, err := p(context.Background(), newHAProxyRequest(http.MethodPost, "/x", http.Header{}, `{"k":1}`))
if err != nil {
t.Fatalf("post: %v", err)
}
if resp.Metadata.StatusCode != http.StatusOK {
t.Fatalf("write status: %d", resp.Metadata.StatusCode)
}
if calls := writer.snapshot(); len(calls) != 1 || calls[0] != "POST /x" {
t.Fatalf("writer calls: %+v", calls)
}
if calls := reader.snapshot(); len(calls) != 0 {
t.Fatalf("reader must not have seen the write: %+v", calls)
}
}
// TestBaseHAReadHitsEnclosingHost proves GET goes to the Host (round-robin
// ClusterIP target), NOT the writer URL.
func TestBaseHAReadHitsEnclosingHost(t *testing.T) {
writer := &endpointStub{name: "foo-0"}
svc := &endpointStub{name: "clusterip"}
writerSrv := httptest.NewServer(writer.handler())
defer writerSrv.Close()
svcSrv := httptest.NewServer(svc.handler())
defer svcSrv.Close()
leader := &leaderStub{}
leader.set(writerSrv.URL, "foo-0", 1, time.Now().Add(10*time.Second))
leaderSrv := httptest.NewServer(leader.handler())
defer leaderSrv.Close()
u, _ := url.Parse(leaderSrv.URL)
host, port := splitHostPort(u.Host)
_, up := buildTestProxy(t, host, port)
_ = pollUntilWriter(t, up, 2*time.Second)
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{svcSrv.URL},
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": host,
"port": port,
"leader_poll_interval": "100ms",
},
},
}
p := newBaseHAProxy(remote, up)
_, err := p(context.Background(), newHAProxyRequest(http.MethodGet, "/r", http.Header{}, ""))
if err != nil {
t.Fatalf("get: %v", err)
}
if calls := svc.snapshot(); len(calls) != 1 {
t.Fatalf("read must hit ClusterIP host, got %+v", calls)
}
if calls := writer.snapshot(); len(calls) != 0 {
t.Fatalf("read must not hit writer, got %+v", calls)
}
}
// TestBaseHAReadYourWritesPinsReader proves that after a POST, the next
// GET from the same client (same X-Forwarded-For + X-Org-Id) goes to the
// writer (not the round-robin Host), within the configured TTL.
func TestBaseHAReadYourWritesPinsReader(t *testing.T) {
writer := &endpointStub{name: "foo-0"}
svc := &endpointStub{name: "clusterip"}
writerSrv := httptest.NewServer(writer.handler())
defer writerSrv.Close()
svcSrv := httptest.NewServer(svc.handler())
defer svcSrv.Close()
leader := &leaderStub{}
leader.set(writerSrv.URL, "foo-0", 1, time.Now().Add(10*time.Second))
leaderSrv := httptest.NewServer(leader.handler())
defer leaderSrv.Close()
u, _ := url.Parse(leaderSrv.URL)
host, port := splitHostPort(u.Host)
_, up := buildTestProxy(t, host, port)
_ = pollUntilWriter(t, up, 2*time.Second)
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{svcSrv.URL},
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": host,
"port": port,
"leader_poll_interval": "100ms",
"read_your_writes_ttl": "500ms",
},
},
}
p := newBaseHAProxy(remote, up)
hdr := http.Header{
"X-Forwarded-For": []string{"1.2.3.4"},
"X-Org-Id": []string{"org-a"},
}
// Write — marks the client for RYW.
if _, err := p(context.Background(), newHAProxyRequest(http.MethodPost, "/x", hdr, `{}`)); err != nil {
t.Fatal(err)
}
// Read within TTL — must pin to writer.
if _, err := p(context.Background(), newHAProxyRequest(http.MethodGet, "/x", hdr, "")); err != nil {
t.Fatal(err)
}
if got := writer.snapshot(); len(got) != 2 {
t.Fatalf("writer should have seen write+read, got %+v", got)
}
if got := svc.snapshot(); len(got) != 0 {
t.Fatalf("ClusterIP host must not have seen the pinned read, got %+v", got)
}
// After TTL, pin expires — read falls back to round-robin.
time.Sleep(600 * time.Millisecond)
if _, err := p(context.Background(), newHAProxyRequest(http.MethodGet, "/x", hdr, "")); err != nil {
t.Fatal(err)
}
if got := svc.snapshot(); len(got) != 1 {
t.Fatalf("post-TTL read should hit ClusterIP host once, got %+v", got)
}
}
// TestBaseHAWriterHeaderForcesPin covers the X-Base-Writer: required
// opt-in: a GET with this header MUST go to the writer.
func TestBaseHAWriterHeaderForcesPin(t *testing.T) {
writer := &endpointStub{name: "foo-0"}
svc := &endpointStub{name: "clusterip"}
writerSrv := httptest.NewServer(writer.handler())
defer writerSrv.Close()
svcSrv := httptest.NewServer(svc.handler())
defer svcSrv.Close()
leader := &leaderStub{}
leader.set(writerSrv.URL, "foo-0", 1, time.Now().Add(10*time.Second))
leaderSrv := httptest.NewServer(leader.handler())
defer leaderSrv.Close()
u, _ := url.Parse(leaderSrv.URL)
host, port := splitHostPort(u.Host)
_, up := buildTestProxy(t, host, port)
_ = pollUntilWriter(t, up, 2*time.Second)
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{svcSrv.URL},
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": host,
"port": port,
},
},
}
p := newBaseHAProxy(remote, up)
hdr := http.Header{"X-Base-Writer": []string{"required"}}
if _, err := p(context.Background(), newHAProxyRequest(http.MethodGet, "/r", hdr, "")); err != nil {
t.Fatal(err)
}
if got := writer.snapshot(); len(got) != 1 {
t.Fatalf("X-Base-Writer: required must pin read to writer, got %+v", got)
}
if got := svc.snapshot(); len(got) != 0 {
t.Fatalf("ClusterIP host must not see header-pinned read, got %+v", got)
}
}
// TestBaseHAWriterFailureRefreshesLeaderAndRetries covers the 5xx/refused
// recovery path: writer returns 503 → refresh → next writer serves.
func TestBaseHAWriterFailureRefreshesLeaderAndRetries(t *testing.T) {
writerA := &endpointStub{name: "foo-0"}
writerA.setStatus(503)
writerB := &endpointStub{name: "foo-1"}
writerASrv := httptest.NewServer(writerA.handler())
defer writerASrv.Close()
writerBSrv := httptest.NewServer(writerB.handler())
defer writerBSrv.Close()
leader := &leaderStub{}
leader.set(writerASrv.URL, "foo-0", 1, time.Now().Add(10*time.Second))
leaderSrv := httptest.NewServer(leader.handler())
defer leaderSrv.Close()
u, _ := url.Parse(leaderSrv.URL)
host, port := splitHostPort(u.Host)
_, up := buildTestProxy(t, host, port)
_ = pollUntilWriter(t, up, 2*time.Second)
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{writerBSrv.URL}, // irrelevant for the write path
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": host,
"port": port,
"leader_poll_interval": "100ms",
},
},
}
p := newBaseHAProxy(remote, up)
// Simulate the failover: leader stub now reports writerB as leader,
// term bumped. The proxy doesn't know yet — the refresh must discover.
flipDone := make(chan struct{})
go func() {
time.Sleep(10 * time.Millisecond)
leader.set(writerBSrv.URL, "foo-1", 2, time.Now().Add(10*time.Second))
close(flipDone)
}()
resp, err := p(context.Background(), newHAProxyRequest(http.MethodPost, "/x", http.Header{}, "{}"))
<-flipDone
if err != nil {
t.Fatalf("post: %v", err)
}
if resp.Metadata.StatusCode != http.StatusOK {
t.Fatalf("final status: %d", resp.Metadata.StatusCode)
}
if got := writerA.snapshot(); len(got) != 1 {
t.Fatalf("writerA (stale) should have been tried once: %+v", got)
}
if got := writerB.snapshot(); len(got) != 1 {
t.Fatalf("writerB (new) should have served the retry: %+v", got)
}
}
// TestBaseHAWriterDoubleFailureDoesNotStorm proves one-retry cap — if the
// refreshed writer is also failing, we return 503 rather than looping.
func TestBaseHAWriterDoubleFailureDoesNotStorm(t *testing.T) {
writer := &endpointStub{name: "foo-0"}
writer.setStatus(503)
writerSrv := httptest.NewServer(writer.handler())
defer writerSrv.Close()
leader := &leaderStub{}
leader.set(writerSrv.URL, "foo-0", 1, time.Now().Add(10*time.Second))
leaderSrv := httptest.NewServer(leader.handler())
defer leaderSrv.Close()
u, _ := url.Parse(leaderSrv.URL)
host, port := splitHostPort(u.Host)
_, up := buildTestProxy(t, host, port)
_ = pollUntilWriter(t, up, 2*time.Second)
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{writerSrv.URL},
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": host,
"port": port,
"leader_poll_interval": "100ms",
},
},
}
p := newBaseHAProxy(remote, up)
resp, err := p(context.Background(), newHAProxyRequest(http.MethodPost, "/x", http.Header{}, "{}"))
if err != nil {
t.Fatalf("post: %v", err)
}
if resp.Metadata.StatusCode < 500 {
t.Fatalf("expected 5xx after double failure, got %d", resp.Metadata.StatusCode)
}
// Exactly two calls: first + retry. NOT more.
if got := len(writer.snapshot()); got != 2 {
t.Fatalf("writer should have seen at most 2 attempts, got %d", got)
}
}
// TestBaseHAStaleCacheFailsSecure proves that when the lease has expired
// AND we haven't heard from the poller in >2x the interval, a write is
// rejected with 503 instead of being sent to a known-dead pod.
func TestBaseHAStaleCacheFailsSecure(t *testing.T) {
writerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer writerSrv.Close()
leader := &leaderStub{}
leader.set(writerSrv.URL, "foo-0", 1, time.Now().Add(-1*time.Hour))
leaderSrv := httptest.NewServer(leader.handler())
defer leaderSrv.Close()
u, _ := url.Parse(leaderSrv.URL)
host, port := splitHostPort(u.Host)
_, up := buildTestProxy(t, host, port)
// Seed the snapshot then stop the poller so fetchedAt goes stale.
_ = pollUntilWriter(t, up, 2*time.Second)
up.close()
// Force fetchedAt into the past to simulate sustained poll failure.
prev := up.state.Load()
up.state.Store(&writerState{
leaderURL: prev.leaderURL,
nodeID: prev.nodeID,
term: prev.term,
leaseExpires: time.Now().Add(-1 * time.Minute), // expired
fetchedAt: time.Now().Add(-5 * time.Second), // > 2x interval
})
if up.currentWriter() != "" {
t.Fatalf("stale state must return empty writer")
}
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{writerSrv.URL},
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": host,
"port": port,
},
},
}
p := newBaseHAProxy(remote, up)
resp, err := p(context.Background(), newHAProxyRequest(http.MethodPost, "/x", http.Header{}, "{}"))
if err != nil {
t.Fatal(err)
}
if resp.Metadata.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("want 503 on stale-cache write, got %d", resp.Metadata.StatusCode)
}
}
// TestBaseHAForwardsHeaders proves gateway-injected identity headers
// (X-User-Id / X-Org-Id / X-Roles) propagate to the upstream pod.
func TestBaseHAForwardsHeaders(t *testing.T) {
writer := &endpointStub{name: "foo-0"}
writerSrv := httptest.NewServer(writer.handler())
defer writerSrv.Close()
leader := &leaderStub{}
leader.set(writerSrv.URL, "foo-0", 1, time.Now().Add(10*time.Second))
leaderSrv := httptest.NewServer(leader.handler())
defer leaderSrv.Close()
u, _ := url.Parse(leaderSrv.URL)
host, port := splitHostPort(u.Host)
_, up := buildTestProxy(t, host, port)
_ = pollUntilWriter(t, up, 2*time.Second)
// Instrument to capture headers at the writer side.
var captured http.Header
writer.mu.Lock()
writer.calls = nil
writer.mu.Unlock()
// Replace writerSrv handler inline — we need to see headers.
writerSrv.Close()
realWriter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer realWriter.Close()
leader.set(realWriter.URL, "foo-0", 2, time.Now().Add(10*time.Second))
up.kickRefresh()
time.Sleep(150 * time.Millisecond)
remote := &config.Backend{
URLPattern: "/_test",
Host: []string{realWriter.URL},
ExtraConfig: config.ExtraConfig{
BaseHANamespace: map[string]any{
"service_dns": host,
"port": port,
},
},
}
p := newBaseHAProxy(remote, up)
hdr := http.Header{
"X-User-Id": []string{"user-z"},
"X-Org-Id": []string{"org-hanzo"},
"X-Roles": []string{"admin"},
}
if _, err := p(context.Background(), newHAProxyRequest(http.MethodPost, "/x", hdr, "{}")); err != nil {
t.Fatal(err)
}
for k, want := range map[string]string{"X-User-Id": "user-z", "X-Org-Id": "org-hanzo", "X-Roles": "admin"} {
if got := captured.Get(k); got != want {
t.Fatalf("header %s: want %q, got %q", k, want, got)
}
}
}
// TestClientPinKeyComposition covers the read-your-writes client identity
// — same IP + same org must collide; different IP or different org must not.
func TestClientPinKeyComposition(t *testing.T) {
h := func(kv ...string) http.Header {
out := http.Header{}
for i := 0; i+1 < len(kv); i += 2 {
out.Set(kv[i], kv[i+1])
}
return out
}
cases := []struct {
a, b http.Header
same bool
}{
{h("X-Forwarded-For", "1.2.3.4", "X-Org-Id", "org-a"),
h("X-Forwarded-For", "1.2.3.4", "X-Org-Id", "org-a"), true},
{h("X-Forwarded-For", "1.2.3.4", "X-Org-Id", "org-a"),
h("X-Forwarded-For", "1.2.3.5", "X-Org-Id", "org-a"), false},
{h("X-Forwarded-For", "1.2.3.4", "X-Org-Id", "org-a"),
h("X-Forwarded-For", "1.2.3.4", "X-Org-Id", "org-b"), false},
// Multi-IP XFF — take the first (client's).
{h("X-Forwarded-For", "1.2.3.4, 10.0.0.1", "X-Org-Id", "org-a"),
h("X-Forwarded-For", "1.2.3.4", "X-Org-Id", "org-a"), true},
}
for i, c := range cases {
ka := clientPinKey(c.a)
kb := clientPinKey(c.b)
got := ka == kb && ka != ""
if got != c.same {
t.Fatalf("case %d: a=%q b=%q got match=%v want %v", i, ka, kb, got, c.same)
}
}
}
func splitHostPort(hp string) (string, int) {
host, ps, ok := strings.Cut(hp, ":")
if !ok {
return hp, 0
}
var p int
_, _ = fmt.Sscanf(ps, "%d", &p)
return host, p
}