package connectsrv_test import ( "context" "net/http" "net/http/httptest" "testing" "time" "connectrpc.com/connect" "scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/config" "scrabble/gateway/internal/connectsrv" "scrabble/gateway/internal/push" "scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/session" "scrabble/gateway/internal/transcode" edgev1 "scrabble/gateway/proto/edge/v1" "scrabble/gateway/proto/edge/v1/edgev1connect" fb "scrabble/pkg/fbs/scrabblefb" ) // newEdge wires a connectsrv.Server over a fake backend and returns a Connect // client plus a cleanup func. The client-version gate is off. func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) { return newEdgeMin(t, "", backendHandler) } // newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off). func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) { t.Helper() backendSrv := httptest.NewServer(backendHandler) backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second) if err != nil { t.Fatalf("backendclient: %v", err) } edge := connectsrv.NewServer(connectsrv.Deps{ Registry: transcode.NewRegistry(backend, nil), Sessions: session.NewCache(backend, time.Minute, 100), Limiter: ratelimit.New(), Hub: push.NewHub(0), RateLimit: config.DefaultRateLimit(), Heartbeat: 15 * time.Second, MinClientVersion: minVersion, }) edgeSrv := httptest.NewServer(edge.HTTPHandler()) client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL) return client, func() { edgeSrv.Close() _ = backend.Close() backendSrv.Close() } } // newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off). func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) { t.Helper() backendSrv := httptest.NewServer(backendHandler) backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second) if err != nil { t.Fatalf("backendclient: %v", err) } edge := connectsrv.NewServer(connectsrv.Deps{ Registry: transcode.NewRegistry(backend, nil), Sessions: session.NewCache(backend, time.Minute, 100), Limiter: ratelimit.New(), Hub: push.NewHub(0), RateLimit: config.DefaultRateLimit(), Heartbeat: 15 * time.Second, MinClientVersion: minVersion, RecommendedClientVersion: recommendedVersion, }) edgeSrv := httptest.NewServer(edge.HTTPHandler()) client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL) return client, func() { edgeSrv.Close() _ = backend.Close() backendSrv.Close() } } func TestExecuteGuestAuthOK(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) }) defer cleanup() resp, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{ MessageType: transcode.MsgAuthGuest, RequestId: "req-1", })) if err != nil { t.Fatalf("execute: %v", err) } if resp.Msg.GetResultCode() != "ok" || resp.Msg.GetRequestId() != "req-1" { t.Fatalf("result = %q req_id = %q", resp.Msg.GetResultCode(), resp.Msg.GetRequestId()) } sess := fb.GetRootAsSession(resp.Msg.GetPayload(), 0) if string(sess.Token()) != "tok" || !sess.IsGuest() { t.Fatalf("session decoded wrong: %q guest=%v", sess.Token(), sess.IsGuest()) } } // TestExecuteGuestGate verifies the NonGuest op flag: a guest is rejected with the // guest_forbidden result code before any backend call, while a guest may still run // an authenticated op that is not guest-gated. func TestExecuteGuestGate(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/internal/sessions/resolve": _, _ = w.Write([]byte(`{"user_id":"g-1","is_guest":true}`)) case "/api/v1/user/feedback/unread": _, _ = w.Write([]byte(`{"reply_unread":false}`)) case "/api/v1/user/feedback": t.Errorf("guest feedback.submit must not reach the backend") default: t.Errorf("unexpected backend path %s", r.URL.Path) } }) defer cleanup() submit := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackSubmit, RequestId: "rq-1"}) submit.Header().Set("Authorization", "Bearer tok") resp, err := client.Execute(context.Background(), submit) if err != nil { t.Fatalf("execute submit: %v", err) } if resp.Msg.GetResultCode() != "guest_forbidden" { t.Fatalf("submit result = %q, want guest_forbidden", resp.Msg.GetResultCode()) } unread := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackUnread}) unread.Header().Set("Authorization", "Bearer tok") resp, err = client.Execute(context.Background(), unread) if err != nil { t.Fatalf("execute unread: %v", err) } if resp.Msg.GetResultCode() != "ok" { t.Fatalf("unread result = %q, want ok", resp.Msg.GetResultCode()) } } // TestExecuteVersionGate verifies the client-version gate on the unary path: a build older // than the configured minimum is turned away with the update_required result code before the // registry lookup (an unknown message type is gated too, proving the short-circuit), while an // equal, newer, absent, or unparseable version passes; and the gate is dormant when unset. func TestExecuteVersionGate(t *testing.T) { // The backend answers auth.guest with a session; a gated call never reaches it. backend := func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) } tests := []struct { name string min string header string msgType string want string }{ {"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"}, {"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"}, {"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"}, {"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"}, {"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"}, {"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client, cleanup := newEdgeMin(t, tc.min, backend) defer cleanup() req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"}) if tc.header != "" { req.Header().Set("X-Client-Version", tc.header) } resp, err := client.Execute(context.Background(), req) if err != nil { t.Fatalf("execute: %v", err) } if got := resp.Msg.GetResultCode(); got != tc.want { t.Fatalf("result_code = %q, want %q", got, tc.want) } }) } } // TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or // above the hard minimum but below the recommended version gets the X-Update-Recommended response // header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version // and a dormant soft tier get no header. func TestExecuteUpdateRecommended(t *testing.T) { backend := func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) } tests := []struct { name string min string rec string header string wantResult string wantHeader bool }{ {"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true}, {"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true}, {"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false}, {"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false}, {"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false}, {"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false}, {"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false}, {"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true}, {"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend) defer cleanup() req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"}) if tc.header != "" { req.Header().Set("X-Client-Version", tc.header) } resp, err := client.Execute(context.Background(), req) if err != nil { t.Fatalf("execute: %v", err) } if got := resp.Msg.GetResultCode(); got != tc.wantResult { t.Fatalf("result_code = %q, want %q", got, tc.wantResult) } if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader { t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader) } }) } } // TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with // FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which // fails Unauthenticated with no session, proving it passed the version gate). func TestSubscribeVersionGate(t *testing.T) { noBackend := func(_ http.ResponseWriter, _ *http.Request) {} tests := []struct { name string min string header string want connect.Code }{ {"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition}, {"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated}, {"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client, cleanup := newEdgeMin(t, tc.min, noBackend) defer cleanup() req := connect.NewRequest(&edgev1.SubscribeRequest{}) if tc.header != "" { req.Header().Set("X-Client-Version", tc.header) } stream, err := client.Subscribe(context.Background(), req) if err == nil { for stream.Receive() { } err = stream.Err() } if got := connect.CodeOf(err); got != tc.want { t.Fatalf("code = %v, want %v", got, tc.want) } }) } } func TestExecuteAuthedRequiresSession(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { t.Error("backend must not be called without a session") }) defer cleanup() _, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{ MessageType: transcode.MsgProfileGet, })) if connect.CodeOf(err) != connect.CodeUnauthenticated { t.Fatalf("code = %v, want Unauthenticated", connect.CodeOf(err)) } } // TestExecuteRateLimitedTracked verifies a limiter rejection returns // ResourceExhausted and lands in the rejection tracker under the public class, // keyed by the client IP. func TestExecuteRateLimitedTracked(t *testing.T) { backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) })) defer backendSrv.Close() backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second) if err != nil { t.Fatalf("backendclient: %v", err) } defer func() { _ = backend.Close() }() limits := config.DefaultRateLimit() limits.PublicPerMinute, limits.PublicBurst = 1, 1 tracker := ratelimit.NewTracker() edge := connectsrv.NewServer(connectsrv.Deps{ Registry: transcode.NewRegistry(backend, nil), Sessions: session.NewCache(backend, time.Minute, 100), Limiter: ratelimit.New(), Tracker: tracker, Hub: push.NewHub(0), RateLimit: limits, Heartbeat: 15 * time.Second, }) edgeSrv := httptest.NewServer(edge.HTTPHandler()) defer edgeSrv.Close() client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL) if _, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{ MessageType: transcode.MsgAuthGuest, })); err != nil { t.Fatalf("first execute: %v", err) } _, err = client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{ MessageType: transcode.MsgAuthGuest, })) if connect.CodeOf(err) != connect.CodeResourceExhausted { t.Fatalf("code = %v, want ResourceExhausted", connect.CodeOf(err)) } entries := tracker.Drain() if len(entries) != 1 { t.Fatalf("tracker drained %d entries, want 1", len(entries)) } if e := entries[0]; e.Class != "public" || e.Key != "127.0.0.1" || e.Rejected != 1 { t.Fatalf("tracked %+v, want public/127.0.0.1 rejected=1", e) } } // TestAdminMountRateLimited verifies the /_gm mount is guarded by the per-IP // admin limiter class ahead of the proxy's Basic-Auth. func TestAdminMountRateLimited(t *testing.T) { backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer backendSrv.Close() backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second) if err != nil { t.Fatalf("backendclient: %v", err) } defer func() { _ = backend.Close() }() limits := config.DefaultRateLimit() limits.AdminPerMinute, limits.AdminBurst = 1, 1 edge := connectsrv.NewServer(connectsrv.Deps{ Registry: transcode.NewRegistry(backend, nil), Sessions: session.NewCache(backend, time.Minute, 100), Limiter: ratelimit.New(), Hub: push.NewHub(0), RateLimit: limits, Heartbeat: 15 * time.Second, AdminProxy: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), }) edgeSrv := httptest.NewServer(edge.HTTPHandler()) defer edgeSrv.Close() first, err := http.Get(edgeSrv.URL + "/_gm/") if err != nil { t.Fatalf("first /_gm: %v", err) } _ = first.Body.Close() if first.StatusCode != http.StatusOK { t.Fatalf("first /_gm = %d, want 200", first.StatusCode) } second, err := http.Get(edgeSrv.URL + "/_gm/") if err != nil { t.Fatalf("second /_gm: %v", err) } _ = second.Body.Close() if second.StatusCode != http.StatusTooManyRequests { t.Fatalf("second /_gm = %d, want 429", second.StatusCode) } } // TestExecuteOversizedPayloadRejected verifies the request-body cap: an Execute // message above GATEWAY_MAX_BODY_BYTES is refused at the edge without reaching // the backend. func TestExecuteOversizedPayloadRejected(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { t.Error("backend must not be called for an oversized payload") }) defer cleanup() _, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{ MessageType: transcode.MsgAuthGuest, Payload: make([]byte, config.DefaultMaxBodyBytes+1), })) if connect.CodeOf(err) != connect.CodeResourceExhausted { t.Fatalf("code = %v, want ResourceExhausted", connect.CodeOf(err)) } } // TestRootRedirectsToApp verifies the gateway no longer serves a landing at "/" // (it lives in the landing container): a stray root hit is redirected // to the app shell. func TestRootRedirectsToApp(t *testing.T) { front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler()) defer front.Close() client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Get(front.URL + "/") if err != nil { t.Fatalf("get /: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusPermanentRedirect || resp.Header.Get("Location") != "/app/" { t.Fatalf("GET / = %d -> %q, want 308 -> /app/", resp.StatusCode, resp.Header.Get("Location")) } } func TestExecuteUnknownMessageType(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {}) defer cleanup() _, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{ MessageType: "does.not.exist", })) if connect.CodeOf(err) != connect.CodeNotFound { t.Fatalf("code = %v, want NotFound", connect.CodeOf(err)) } }