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.
121 lines
3.8 KiB
Go
121 lines
3.8 KiB
Go
package config
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
pkgtel "scrabble/pkg/telemetry"
|
|
)
|
|
|
|
// TestLoadTelemetryDefaults verifies the gateway telemetry defaults: the
|
|
// "scrabble-gateway" service name and both exporters off.
|
|
func TestLoadTelemetryDefaults(t *testing.T) {
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Telemetry.ServiceName != defaultServiceName {
|
|
t.Errorf("Telemetry.ServiceName = %q, want %q", c.Telemetry.ServiceName, defaultServiceName)
|
|
}
|
|
if c.Telemetry.TracesExporter != pkgtel.ExporterNone || c.Telemetry.MetricsExporter != pkgtel.ExporterNone {
|
|
t.Errorf("exporters = %q/%q, want none/none", c.Telemetry.TracesExporter, c.Telemetry.MetricsExporter)
|
|
}
|
|
}
|
|
|
|
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported
|
|
// set fails validation.
|
|
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
|
t.Setenv("GATEWAY_OTEL_METRICS_EXPORTER", "prometheus")
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
|
|
}
|
|
}
|
|
|
|
// TestLoadMaxBodyBytes verifies the body-cap default and that a non-positive
|
|
// override fails validation.
|
|
func TestLoadMaxBodyBytes(t *testing.T) {
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.MaxBodyBytes != DefaultMaxBodyBytes {
|
|
t.Errorf("MaxBodyBytes = %d, want %d", c.MaxBodyBytes, DefaultMaxBodyBytes)
|
|
}
|
|
t.Setenv("GATEWAY_MAX_BODY_BYTES", "0")
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("Load: expected an error for a non-positive body cap, got nil")
|
|
}
|
|
}
|
|
|
|
// TestLoadMinClientVersion verifies the client-version gate config: dormant (empty) by
|
|
// default, a parseable version accepted, and an unparseable one rejected.
|
|
func TestLoadMinClientVersion(t *testing.T) {
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.MinClientVersion != "" {
|
|
t.Errorf("MinClientVersion = %q, want empty (gate dormant)", c.MinClientVersion)
|
|
}
|
|
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
|
|
if c, err = Load(); err != nil {
|
|
t.Fatalf("Load with a valid min version: %v", err)
|
|
}
|
|
if c.MinClientVersion != "v1.16.0" {
|
|
t.Errorf("MinClientVersion = %q, want %q", c.MinClientVersion, "v1.16.0")
|
|
}
|
|
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "dev")
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("Load: expected an error for an unparseable GATEWAY_MIN_CLIENT_VERSION, got nil")
|
|
}
|
|
}
|
|
|
|
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
|
// the agreed thresholds, and no honeytoken.
|
|
func TestLoadAbuseDefaults(t *testing.T) {
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
want := DefaultAbuse()
|
|
if c.Abuse != want {
|
|
t.Errorf("Abuse = %+v, want %+v", c.Abuse, want)
|
|
}
|
|
if c.Abuse.BanEnabled {
|
|
t.Error("ban must default to disabled (enabled only in prod)")
|
|
}
|
|
}
|
|
|
|
// TestLoadAbuseOverrides verifies the anti-abuse environment variables are parsed.
|
|
func TestLoadAbuseOverrides(t *testing.T) {
|
|
t.Setenv("GATEWAY_ABUSE_BAN_ENABLED", "true")
|
|
t.Setenv("GATEWAY_ABUSE_BAN_THRESHOLD", "50")
|
|
t.Setenv("GATEWAY_ABUSE_BAN_WINDOW", "90s")
|
|
t.Setenv("GATEWAY_ABUSE_BAN_DURATION", "30m")
|
|
t.Setenv("GATEWAY_HONEYTOKEN", "deadbeef")
|
|
c, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
want := AbuseConfig{
|
|
BanEnabled: true,
|
|
BanThreshold: 50,
|
|
BanWindow: 90 * time.Second,
|
|
BanDuration: 30 * time.Minute,
|
|
Honeytoken: "deadbeef",
|
|
}
|
|
if c.Abuse != want {
|
|
t.Errorf("Abuse = %+v, want %+v", c.Abuse, want)
|
|
}
|
|
}
|
|
|
|
// TestLoadAbuseRejectsBadThreshold verifies an enabled ban with a non-positive
|
|
// threshold fails validation.
|
|
func TestLoadAbuseRejectsBadThreshold(t *testing.T) {
|
|
t.Setenv("GATEWAY_ABUSE_BAN_ENABLED", "true")
|
|
t.Setenv("GATEWAY_ABUSE_BAN_THRESHOLD", "0")
|
|
if _, err := Load(); err == nil {
|
|
t.Fatal("Load: expected an error for an enabled ban with a zero threshold, got nil")
|
|
}
|
|
}
|