Files
CosmicClash/server/steam/web_api_test.go
T
Josh Creek f628ccfd35 feat(auth): wire production Steam sign-in and the client login flow
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.
2026-09-05 10:57:50 +01:00

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)
}
})
}
}