mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
f628ccfd35
newAPIService never supplied SteamLogin, so POST /v1/session/steam always returned 503 auth_unavailable in production. The only implementation was cmd/testkit-api's fake, which derives an identity from the ticket string itself and accepts anything -- so the passing integration path was neither deployable nor secure. On the client side the game started with an empty token and a loopback base URL, and no production code called configure() or login_steam(); the menu entered matchmaking directly, so every request failed ERR_UNAUTHORIZED before reaching the network. Add a real ISteamUserAuth/AuthenticateUserTicket adapter behind an interface, so the production login path is testable with only the Valve call stubbed. It rejects family-shared copies (the account playing does not own the app) and, by default, VAC- or publisher-banned accounts, and refuses malformed tickets locally rather than forwarding them. Crucially it separates our faults from the player's: a Valve outage or a revoked publisher key returns 503, not 401. Answering 401 would tell a legitimate player their login failed and send them to fix an account that is fine while the real fault went unnoticed. A banned identity now returns 403 rather than a misleading 503. Sign-in is configuration-gated on the publisher key and App ID: without them the endpoint keeps returning 503, since silently accepting an unverified ticket would be worse than refusing to authenticate. A returning player keeps the player ID they already had, so ratings, penalties and bans follow the account rather than the session. Client side: acquire a web-API ticket through GodotSteam's async signal -- requesting one returns a handle, not a ticket -- using the existing dynamic-call pattern so stock Godot still parses the project. The endpoint is configurable for release builds, and matchmaking completes sign-in before it will queue. Verified against real PostgreSQL; 232 Godot tests pass.
144 lines
5.2 KiB
Go
144 lines
5.2 KiB
Go
package steam
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const validTicket = "140000008bc0a1f45fd4b4b7e0af2c4a01001001"
|
|
|
|
func stubValve(t *testing.T, status int, body string, inspect func(*http.Request)) WebAPIVerifier {
|
|
t.Helper()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if inspect != nil {
|
|
inspect(r)
|
|
}
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return WebAPIVerifier{PublisherKey: "publisher-key", AppID: 480, Endpoint: server.URL, HTTP: server.Client()}
|
|
}
|
|
|
|
func TestVerifyReturnsIdentityForAnAcceptedTicket(t *testing.T) {
|
|
var seen *http.Request
|
|
verifier := stubValve(t, http.StatusOK,
|
|
`{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":false,"publisherbanned":false}}}`,
|
|
func(r *http.Request) { seen = r })
|
|
identity, err := verifier.Verify(context.Background(), validTicket)
|
|
if err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
if identity.SteamID != "76561198000000001" {
|
|
t.Fatalf("identity = %+v", identity)
|
|
}
|
|
// The publisher key must be sent to Valve and nowhere else; assert it is
|
|
// carried in the request rather than, say, logged or returned.
|
|
if seen.URL.Query().Get("key") != "publisher-key" || seen.URL.Query().Get("appid") != "480" {
|
|
t.Fatalf("request query = %s", seen.URL.RawQuery)
|
|
}
|
|
if seen.URL.Query().Get("ticket") != validTicket {
|
|
t.Fatalf("ticket was not forwarded verbatim: %s", seen.URL.Query().Get("ticket"))
|
|
}
|
|
}
|
|
|
|
func TestVerifyRejectsTicketsValveDoesNotAccept(t *testing.T) {
|
|
for name, body := range map[string]string{
|
|
"explicit failure": `{"response":{"params":{"result":"Failure","steamid":"76561198000000001"}}}`,
|
|
"error object": `{"response":{"error":{"errorcode":101,"errordesc":"Invalid ticket"}}}`,
|
|
"empty response": `{"response":{}}`,
|
|
"no steam id": `{"response":{"params":{"result":"OK"}}}`,
|
|
"bogus steam id": `{"response":{"params":{"result":"OK","steamid":"not-a-steam-id"}}}`,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
verifier := stubValve(t, http.StatusOK, body, nil)
|
|
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
|
t.Fatalf("err = %v, want ErrTicketRejected", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVerifyRejectsFamilySharedAndBannedAccounts(t *testing.T) {
|
|
shared := stubValve(t, http.StatusOK,
|
|
`{"response":{"params":{"result":"OK","steamid":"76561198000000002","ownersteamid":"76561198000000001"}}}`, nil)
|
|
if _, err := shared.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
|
t.Fatalf("family-shared copy accepted: %v", err)
|
|
}
|
|
|
|
banned := stubValve(t, http.StatusOK,
|
|
`{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":true}}}`, nil)
|
|
banned.RejectBanned = true
|
|
if _, err := banned.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
|
t.Fatalf("VAC-banned account accepted: %v", err)
|
|
}
|
|
banned.RejectBanned = false
|
|
if _, err := banned.Verify(context.Background(), validTicket); err != nil {
|
|
t.Fatalf("ban enforcement should be configurable: %v", err)
|
|
}
|
|
}
|
|
|
|
// A Valve outage or a revoked publisher key must not read as "this player's
|
|
// ticket is bad", or a legitimate player is told to fix an account that is
|
|
// fine while the real fault goes unnoticed.
|
|
func TestVerifyDistinguishesOurFaultsFromBadTickets(t *testing.T) {
|
|
for name, status := range map[string]int{
|
|
"revoked publisher key": http.StatusForbidden,
|
|
"unauthorized": http.StatusUnauthorized,
|
|
"valve error": http.StatusInternalServerError,
|
|
"valve gateway": http.StatusBadGateway,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
verifier := stubValve(t, status, `{}`, nil)
|
|
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("err = %v, want ErrUnavailable", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("malformed response", func(t *testing.T) {
|
|
verifier := stubValve(t, http.StatusOK, `not json`, nil)
|
|
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("err = %v, want ErrUnavailable", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestVerifyRefusesMalformedTicketsWithoutCallingValve(t *testing.T) {
|
|
called := false
|
|
verifier := stubValve(t, http.StatusOK, `{}`, func(*http.Request) { called = true })
|
|
for name, ticket := range map[string]string{
|
|
"empty": "",
|
|
"whitespace": " ",
|
|
"not hex": "zzzz-not-a-ticket",
|
|
"oversized": strings.Repeat("a", MaxTicketBytes+1),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := verifier.Verify(context.Background(), ticket); !errors.Is(err, ErrTicketRejected) {
|
|
t.Fatalf("err = %v, want ErrTicketRejected", err)
|
|
}
|
|
})
|
|
}
|
|
if called {
|
|
t.Fatal("a malformed ticket was forwarded to Valve")
|
|
}
|
|
}
|
|
|
|
func TestVerifyIsUnavailableWithoutCredentials(t *testing.T) {
|
|
for name, verifier := range map[string]WebAPIVerifier{
|
|
"no key": {AppID: 480},
|
|
"no app id": {PublisherKey: "publisher-key"},
|
|
"neither": {},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("err = %v, want ErrUnavailable", err)
|
|
}
|
|
})
|
|
}
|
|
}
|