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