ff55d5de83
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The backend recorded 172.19.0.9 — the gateway's own docker connection address — as the client IP for all users: the account's last-login IP shown in the admin console, and it never reached the backend access log. The gateway forwarded the client IP as X-Forwarded-For only on chat/feedback calls; every other backend call (including the profile fetch that stamps last_login_ip) sent none, so the backend fell back to the peer address. Carry the client IP on the request context (WithClientIP, mirroring WithPlatform) and set it once per request in the Connect edge, so the backend client injects X-Forwarded-For on every downstream REST call. Also add the resolved client IP to the backend access log. Test: WithClientIP rides a non-chat call (Profile) as X-Forwarded-For, and is absent when no IP is set. Docs updated (ARCHITECTURE gateway↔backend + edge).
166 lines
5.6 KiB
Go
166 lines
5.6 KiB
Go
package backendclient_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"scrabble/gateway/internal/backendclient"
|
|
"scrabble/gateway/internal/ratelimit"
|
|
)
|
|
|
|
// TestReportRateLimited verifies the rejection report reaches the backend's
|
|
// internal endpoint with the agreed JSON shape and no user identity.
|
|
func TestReportRateLimited(t *testing.T) {
|
|
var got struct {
|
|
WindowSeconds int `json:"window_seconds"`
|
|
Entries []ratelimit.Rejection `json:"entries"`
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/internal/ratelimit/report" {
|
|
t.Errorf("call = %s %s, want POST /api/v1/internal/ratelimit/report", r.Method, r.URL.Path)
|
|
}
|
|
if uid := r.Header.Get("X-User-ID"); uid != "" {
|
|
t.Errorf("X-User-ID = %q, want empty", uid)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
|
t.Errorf("decode report: %v", err)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("backendclient: %v", err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
|
|
entries := []ratelimit.Rejection{{Class: "user", Key: "u-1", Rejected: 5}}
|
|
if err := c.ReportRateLimited(context.Background(), 30, entries); err != nil {
|
|
t.Fatalf("ReportRateLimited: %v", err)
|
|
}
|
|
if got.WindowSeconds != 30 || len(got.Entries) != 1 || got.Entries[0] != entries[0] {
|
|
t.Fatalf("backend received %+v, want window 30 + %+v", got, entries[0])
|
|
}
|
|
}
|
|
|
|
// TestSyncBans verifies the gateway reports its active bans to the backend's
|
|
// internal endpoint and returns the operator unban list the backend replies with.
|
|
func TestSyncBans(t *testing.T) {
|
|
var got struct {
|
|
Active []ratelimit.Ban `json:"active"`
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/internal/bans/sync" {
|
|
t.Errorf("call = %s %s, want POST /api/v1/internal/bans/sync", r.Method, r.URL.Path)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
|
t.Errorf("decode sync: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"unban":["203.0.113.9"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("backendclient: %v", err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
|
|
active := []ratelimit.Ban{{IP: "198.51.100.4", Reason: ratelimit.ReasonTripwire}}
|
|
unban, err := c.SyncBans(context.Background(), active)
|
|
if err != nil {
|
|
t.Fatalf("SyncBans: %v", err)
|
|
}
|
|
if len(got.Active) != 1 || got.Active[0].IP != "198.51.100.4" || got.Active[0].Reason != ratelimit.ReasonTripwire {
|
|
t.Fatalf("backend received active = %+v, want one tripwire ban for 198.51.100.4", got.Active)
|
|
}
|
|
if len(unban) != 1 || unban[0] != "203.0.113.9" {
|
|
t.Fatalf("unban = %v, want [203.0.113.9]", unban)
|
|
}
|
|
}
|
|
|
|
// TestXPlatformInjection verifies the trusted platform carried on the context
|
|
// (WithPlatform) rides an authenticated backend request as X-Platform, and that an
|
|
// untrusted context (no platform) sends no header at all — the fail-closed default.
|
|
func TestXPlatformInjection(t *testing.T) {
|
|
var gotPlatform string
|
|
var hadHeader bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPlatform = r.Header.Get("X-Platform")
|
|
_, hadHeader = r.Header["X-Platform"]
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("backendclient: %v", err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
|
|
t.Run("trusted forwards header", func(t *testing.T) {
|
|
ctx := backendclient.WithPlatform(context.Background(), "vk/ios")
|
|
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
|
t.Fatalf("Profile: %v", err)
|
|
}
|
|
if gotPlatform != "vk/ios" {
|
|
t.Fatalf("X-Platform = %q, want vk/ios", gotPlatform)
|
|
}
|
|
})
|
|
|
|
t.Run("untrusted omits header", func(t *testing.T) {
|
|
hadHeader = true // ensure the handler actually clears it
|
|
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
|
t.Fatalf("Profile: %v", err)
|
|
}
|
|
if hadHeader {
|
|
t.Fatal("X-Platform must be absent for an untrusted context")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every
|
|
// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend
|
|
// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the
|
|
// gateway's own connection. Absent when no client IP is set.
|
|
func TestXForwardedForInjection(t *testing.T) {
|
|
var gotXFF string
|
|
var hadHeader bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotXFF = r.Header.Get("X-Forwarded-For")
|
|
_, hadHeader = r.Header["X-Forwarded-For"]
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("backendclient: %v", err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
|
|
t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) {
|
|
ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7")
|
|
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
|
t.Fatalf("Profile: %v", err)
|
|
}
|
|
if gotXFF != "203.0.113.7" {
|
|
t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF)
|
|
}
|
|
})
|
|
|
|
t.Run("no client IP omits the header", func(t *testing.T) {
|
|
hadHeader = true
|
|
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
|
t.Fatalf("Profile: %v", err)
|
|
}
|
|
if hadHeader {
|
|
t.Fatal("X-Forwarded-For must be absent when no client IP is set")
|
|
}
|
|
})
|
|
}
|