Files
scrabble-game/gateway/internal/backendclient/client_test.go
T
Ilia Denisov 92633f935e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
feat(payments): trusted platform signal on the session
Record the execution platform (kind vk|telegram|direct + device subtype
ios|android|web) on each session, captured at creation and carried
gateway->backend as a trusted X-Platform header, so the upcoming
store-compliance gate has an unforgeable execution context.

- backend.sessions gains nullable platform_kind/platform_subtype columns
  (migration 00011, CHECK-constrained, jet regenerated); session.Platform
  captures them at mint, resolve returns them, middleware exposes platform(c).
  kind is derived from the establish endpoint, never a client field; the
  account-merge session mint inherits the caller's platform.
- gateway derives the platform (VK subtype from the signed vk_platform via
  vkauth, Telegram/direct best-effort from the client) and injects X-Platform
  on every authenticated backend call through the request context.
- ui submits a best-effort device subtype on the telegram/guest/email login
  requests (new FBS subtype field); VK is server-derived from the signed params.
- an unattributed session is untrusted (view-only); VK/TG self-heal on the next
  cold-start re-mint, direct/email on re-login.

Signal plumbing only, no user-visible change; X-Platform is inert until the
gate consumes it.
2026-07-08 03:31:51 +02:00

125 lines
4.2 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")
}
})
}