a8f78c7880
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
429 lines
16 KiB
Go
429 lines
16 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()
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|