a57fd355ba
Introduce a minimum-supported-client gate so a future incompatible wire change can turn away installed builds too old to speak it, cleanly, instead of letting them crash on decode. It rides the outermost stable layer (an HTTP header), never the FlatBuffers payload. Gateway: - New internal/clientver: dependency-free parse + compare of the leading MAJOR.MINOR.PATCH (a git-describe suffix is tolerated). - GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load). - connectsrv checks the X-Client-Version header before decoding the payload: Execute returns result_code="update_required" (before the registry lookup), Subscribe returns FailedPrecondition. It fails open on an absent or garbled header — the header is a client-controlled compatibility signal, not an access control. Client: - Attach X-Client-Version on every call. - A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the update_required sentinel; a mock __update hook drives the e2e. Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK / Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate tests, retry.test.ts, Playwright update.spec.ts.
354 lines
13 KiB
Go
354 lines
13 KiB
Go
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()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|