Files
scrabble-game/gateway/internal/connectsrv/server_test.go
T
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00

231 lines
7.9 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.
func newEdge(t *testing.T, 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,
})
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())
}
}
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))
}
}