Files
bd2/go/internal/server/transport/http_test.go
T

296 lines
13 KiB
Go

package transport
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"bd2server/internal/server/authconfig"
"bd2server/internal/server/bootstrap"
"bd2server/internal/server/lifecycle"
"bd2server/internal/server/resourcepolicy"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestDrainingServerRejectsGameRequestsWithReconnectMarker(t *testing.T) {
gate := lifecycle.NewGate()
handler := HTTP{Availability: gate, InstanceID: "instance-a"}.Handler()
ready := httptest.NewRecorder()
handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if ready.Code != http.StatusOK {
t.Fatalf("initial readiness=%d", ready.Code)
}
gate.Drain()
ready = httptest.NewRecorder()
handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if ready.Code != http.StatusServiceUnavailable {
t.Fatalf("draining readiness=%d", ready.Code)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted")))
if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" ||
response.Header().Get("X-BD2-Reconnect-Reason") != "rolling-restart" ||
response.Header().Get("Retry-After") != "1" || response.Header().Get("Connection") != "close" {
t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String())
}
health := httptest.NewRecorder()
handler.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if health.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness alias during drain=%d", health.Code)
}
live := httptest.NewRecorder()
handler.ServeHTTP(live, httptest.NewRequest(http.MethodGet, "/livez", nil))
if live.Code != http.StatusOK {
t.Fatalf("liveness during drain=%d", live.Code)
}
runtime := httptest.NewRecorder()
handler.ServeHTTP(runtime, httptest.NewRequest(http.MethodGet, "/client/runtime", nil))
var status struct {
Status string `json:"status"`
ID string `json:"instance_id"`
}
if err := json.Unmarshal(runtime.Body.Bytes(), &status); err != nil || status.Status != "draining" || status.ID != "instance-a" {
t.Fatalf("runtime=%+v err=%v", status, err)
}
}
func TestDrainingServerRejectsAuthenticationMutationBeforeHandler(t *testing.T) {
gate := lifecycle.NewGate()
called := false
handler := HTTP{
Availability: gate,
AuthenticationHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}),
}.Handler()
gate.Drain()
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/auth/session/refresh", strings.NewReader(`{}`)))
if called || response.Code != http.StatusServiceUnavailable ||
response.Header().Get("X-BD2-Reconnect") != "1" {
t.Fatalf("called=%v status=%d headers=%v", called, response.Code, response.Header())
}
}
func TestBootstrapRoundTrip(t *testing.T) {
cfg := bootstrap.Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: "test-client", BundleVer: "test-bundle"}
now := func() time.Time { return time.UnixMilli(12345) }
h := HTTP{Dispatcher: Bootstrap{Config: cfg, Now: now}, Now: now}.Handler()
for _, path := range []string{"/MaintenanceInfo", "/ServerInfo", "/NoticeInfo", "/ServerNowTime"} {
request := base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))
req := httptest.NewRequest(http.MethodPut, "/game"+path, strings.NewReader(request))
res := httptest.NewRecorder()
h.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s: %d %s", path, res.Code, res.Body.String())
}
var envelope Envelope
if err := json.Unmarshal(res.Body.Bytes(), &envelope); err != nil || envelope.ErrorType != 0 || envelope.ServerNowTime != 12345 {
t.Fatalf("%s: %v %+v", path, err, envelope)
}
if _, err := base64.StdEncoding.DecodeString(envelope.Data); err != nil {
t.Fatalf("%s: %v", path, err)
}
}
}
func TestAuthenticationConfig(t *testing.T) {
h := HTTP{Authentication: authconfig.Config{
Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER",
Providers: map[string]authconfig.ProviderConfig{
"discord": {ClientID: "d", ClientSecretEnv: "DS"},
"google": {ClientID: "g", ClientSecretEnv: "GS"},
},
}}.Handler()
res := httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/auth/config", nil))
if res.Code != http.StatusOK || res.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("status=%d cache=%q body=%q", res.Code, res.Header().Get("Cache-Control"), res.Body.String())
}
var config authconfig.Public
if err := json.Unmarshal(res.Body.Bytes(), &config); err != nil || config.Mode != "oauth" || len(config.Providers) != 2 {
t.Fatalf("config=%+v err=%v", config, err)
}
res = httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodPost, "/auth/config", nil))
if res.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST status=%d", res.Code)
}
}
type cookieRawDispatcher struct{}
func (cookieRawDispatcher) DispatchRaw(string, []byte, string) (RawReply, error) {
return RawReply{Body: []byte(`{}`), Cookie: "0123456789abcdef0123456789abcdef0123456789abcdef|1"}, nil
}
type failedRawDispatcher struct {
err error
}
func (d failedRawDispatcher) DispatchRaw(string, []byte, string) (RawReply, error) {
return RawReply{}, d.err
}
func TestExpiredGameSessionUsesDedicatedHTTPMarker(t *testing.T) {
h := HTTP{Raw: failedRawDispatcher{err: ErrGameSessionExpired}}.Handler()
request := httptest.NewRequest(http.MethodPut, "/game/BatchRequest", strings.NewReader("encrypted"))
request.Header.Set("Cookie", "s=0123456789abcdef0123456789abcdef0123456789abcdef|1")
response := httptest.NewRecorder()
h.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || response.Header().Get("X-BD2-Session-Expired") != "1" ||
response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("status=%d marker=%q cache=%q body=%q", response.Code,
response.Header().Get("X-BD2-Session-Expired"), response.Header().Get("Cache-Control"), response.Body.String())
}
}
func TestInvalidGameAccessCredentialUsesDedicatedHTTPMarker(t *testing.T) {
h := HTTP{Raw: failedRawDispatcher{err: ErrAccessCredentialInvalid}}.Handler()
response := httptest.NewRecorder()
h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/LoginUser", strings.NewReader("encrypted")))
if response.Code != http.StatusUnauthorized || response.Header().Get("X-BD2-Access-Expired") != "1" ||
response.Header().Get("WWW-Authenticate") != `Bearer error="invalid_token"` ||
response.Header().Get("X-BD2-Session-Expired") != "" {
t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String())
}
}
func TestDomainFailureDoesNotUseExpiredSessionMarker(t *testing.T) {
h := HTTP{Raw: failedRawDispatcher{err: errors.New("mail seed is invalid")}}.Handler()
response := httptest.NewRecorder()
h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted")))
if response.Code != http.StatusBadRequest || response.Header().Get("X-BD2-Session-Expired") != "" {
t.Fatalf("status=%d marker=%q body=%q", response.Code,
response.Header().Get("X-BD2-Session-Expired"), response.Body.String())
}
}
func TestFencedWriterRequestsReconnectInsteadOfDomainFailure(t *testing.T) {
h := HTTP{Raw: failedRawDispatcher{err: stateio.ErrWriterFenced}}.Handler()
response := httptest.NewRecorder()
h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted")))
if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" ||
response.Header().Get("X-BD2-Reconnect-Reason") != "fenced" {
t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String())
}
}
func TestUncertainStateTransactionRequestsProcessRecovery(t *testing.T) {
h := HTTP{Raw: failedRawDispatcher{err: stateio.ErrStateRecoveryRequired}}.Handler()
response := httptest.NewRecorder()
h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted")))
if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" ||
response.Header().Get("X-BD2-Reconnect-Reason") != "state-recovery" {
t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String())
}
}
func TestOAuthGameSessionCookieIsHostOnlySecureAndGameScoped(t *testing.T) {
h := HTTP{
Raw: cookieRawDispatcher{},
Authentication: authconfig.Config{
Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER",
Providers: map[string]authconfig.ProviderConfig{
"discord": {ClientID: "d", ClientSecretEnv: "DS"},
},
},
}.Handler()
response := httptest.NewRecorder()
h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/LoginUser", strings.NewReader("request")))
cookies := response.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("cookies=%v", cookies)
}
cookie := cookies[0]
if cookie.Name != "s" || cookie.Path != "/game/" || !cookie.HttpOnly || !cookie.Secure ||
cookie.SameSite != http.SameSiteLaxMode || cookie.Domain != "" {
t.Fatalf("unsafe game session cookie: %+v", cookie)
}
}
func TestUnknownPacketFailsClosed(t *testing.T) {
h := HTTP{Dispatcher: Bootstrap{}}.Handler()
res := httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/game/InventedPacket", strings.NewReader("AA==")))
if res.Code != http.StatusNotImplemented {
t.Fatalf("unknown packet status: %d", res.Code)
}
}
func TestClientResourcePolicyUsesPUTAndDoesNotCache(t *testing.T) {
policy := resourcepolicy.Public{
Mode: resourcepolicy.ModeServer, ServerDataURL: "https://cdn.example.com/ServerData",
GameDataURL: "https://cdn.example.com/GameData", BundleVersion: "bundle", GameDataVersion: "game-data",
}
h := HTTP{ResourcePolicy: policy}.Handler()
res := httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(`{"cdn_mode":"server"}`)))
if res.Code != http.StatusOK || res.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("status=%d cache=%q body=%q", res.Code, res.Header().Get("Cache-Control"), res.Body.String())
}
var got resourcepolicy.Public
if err := json.Unmarshal(res.Body.Bytes(), &got); err != nil || got != policy {
t.Fatalf("policy=%+v err=%v", got, err)
}
res = httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/client/resources", nil))
if res.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET status=%d", res.Code)
}
}
func TestClientResourcePolicyRejectsInvalidOrMismatchedSelection(t *testing.T) {
policy := resourcepolicy.Public{
Mode: resourcepolicy.ModeServer, ServerDataURL: "https://cdn.example.com/ServerData",
GameDataURL: "https://cdn.example.com/GameData", BundleVersion: "bundle", GameDataVersion: "game-data",
}
h := HTTP{ResourcePolicy: policy}.Handler()
for name, test := range map[string]struct {
body string
status int
}{
"missing": {`{}`, http.StatusBadRequest},
"unknown": {`{"cdn_mode":"server","url":"https://evil.example"}`, http.StatusBadRequest},
"trailing": {`{"cdn_mode":"server"}{}`, http.StatusBadRequest},
"official": {`{"cdn_mode":"official"}`, http.StatusBadRequest},
"local": {`{"cdn_mode":"local"}`, http.StatusBadRequest},
"legacy-self-hosted": {`{"cdn_mode":"self_hosted"}`, http.StatusBadRequest},
"legacy-reverse-proxy": {`{"cdn_mode":"reverse_proxy"}`, http.StatusBadRequest},
"too-large": {`{"cdn_mode":"server","padding":"` + strings.Repeat("x", 16<<10) + `"}`, http.StatusRequestEntityTooLarge},
} {
t.Run(name, func(t *testing.T) {
res := httptest.NewRecorder()
h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(test.body)))
if res.Code != test.status {
t.Fatalf("status=%d want=%d body=%q", res.Code, test.status, res.Body.String())
}
})
}
}
func TestClientResourcePolicyRejectsServerSelectionWhenServerUsesOfficialResources(t *testing.T) {
policy := resourcepolicy.Public{
Mode: resourcepolicy.ModeOfficial, ServerDataURL: resourcepolicy.OfficialServerDataURL,
GameDataURL: resourcepolicy.OfficialGameDataURL, BundleVersion: "bundle", GameDataVersion: "game-data",
}
res := httptest.NewRecorder()
HTTP{ResourcePolicy: policy}.Handler().ServeHTTP(
res,
httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(`{"cdn_mode":"server"}`)),
)
if res.Code != http.StatusConflict {
t.Fatalf("status=%d want=%d body=%q", res.Code, http.StatusConflict, res.Body.String())
}
}