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