test(nano): cover the premium-name cache rules
isPremiumName decides on every third-party login whether the player keeps their name, and none of its rules had a test that fails when the rule breaks: treating a 429 or 5xx from api.mojang.com as "free", swapping the free and taken TTLs, flipping the freshness comparison, answering "free" from an expired taken entry during an outage, or dropping the clear-at-4096 bound. Each of those leaves a squatter holding a name its owner has bought, or grows the cache without limit, with CI green. TestPremiumNameCache drives isPremiumName against a stub that answers with a fixed status and counts lookups, and seeds cache entries at chosen ages. Five mutants of handlers_hasjoined.go, one per rule above, each fail at least one subtest. It does not test an expired "free" entry during an outage; what that case should return is still open.
This commit is contained in:
1 file changed
+77
@@ -2,8 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -374,6 +376,81 @@ func TestHasJoined(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// profileAPIAnswering stands in for api.mojang.com answering every lookup with status and
|
||||||
|
// counts how often it is asked. 200 means taken, 404 free, anything else is an outage.
|
||||||
|
func profileAPIAnswering(t *testing.T, status int) *atomic.Int32 {
|
||||||
|
t.Helper()
|
||||||
|
var n atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n.Add(1)
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
setProfileAPI(t, srv.URL+"/")
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedPremium(name string, taken bool, age time.Duration) {
|
||||||
|
premiumNames.Lock()
|
||||||
|
premiumNames.m[strings.ToLower(name)] = premiumEntry{taken: taken, at: time.Now().Add(-age)}
|
||||||
|
premiumNames.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The premium-name cache decides on each third-party login whether the player keeps their
|
||||||
|
// name. Each case pins one rule that an innocent-looking edit would break unnoticed.
|
||||||
|
func TestPremiumNameCache(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
|
||||||
|
t.Run(fmt.Sprintf("mojang %d is an outage, not a free name", status), func(t *testing.T) {
|
||||||
|
profileAPIAnswering(t, status)
|
||||||
|
if !isPremiumName(ctx, "Notch") {
|
||||||
|
t.Fatal("name treated as free; a squatter would keep it through the outage")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a free answer is asked again once it expires", func(t *testing.T) {
|
||||||
|
n := profileAPIAnswering(t, http.StatusNotFound)
|
||||||
|
seedPremium("Steve0", false, premiumFreeTTL+time.Minute)
|
||||||
|
isPremiumName(ctx, "Steve0")
|
||||||
|
if n.Load() != 1 {
|
||||||
|
t.Fatalf("expired free answer: %d lookups, want 1", n.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("answers within their TTL come from the cache", func(t *testing.T) {
|
||||||
|
n := profileAPIAnswering(t, http.StatusNotFound)
|
||||||
|
seedPremium("Steve0", false, premiumFreeTTL-time.Minute)
|
||||||
|
seedPremium("Notch", true, premiumTakenTTL-time.Hour)
|
||||||
|
if isPremiumName(ctx, "Steve0") || !isPremiumName(ctx, "Notch") || n.Load() != 0 {
|
||||||
|
t.Fatalf("cached answers not served as cached (%d lookups)", n.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an expired taken answer outlives an outage", func(t *testing.T) {
|
||||||
|
profileAPIAnswering(t, http.StatusServiceUnavailable)
|
||||||
|
seedPremium("Notch", true, 2*premiumTakenTTL)
|
||||||
|
if !isPremiumName(ctx, "Notch") {
|
||||||
|
t.Fatal("known premium name treated as free during an outage")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the cache is cleared rather than grown past its bound", func(t *testing.T) {
|
||||||
|
profileAPIAnswering(t, http.StatusNotFound)
|
||||||
|
for i := range premiumCacheMax {
|
||||||
|
seedPremium(fmt.Sprintf("n%d", i), false, 0)
|
||||||
|
}
|
||||||
|
isPremiumName(ctx, "Steve0")
|
||||||
|
premiumNames.Lock()
|
||||||
|
size := len(premiumNames.m)
|
||||||
|
premiumNames.Unlock()
|
||||||
|
if size != 1 {
|
||||||
|
t.Fatalf("cache holds %d entries after passing its bound, want 1", size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// The username namespace is the proxy's, not ours: Velocity keys its player registry on the
|
// The username namespace is the proxy's, not ours: Velocity keys its player registry on the
|
||||||
// NAME, so a third-party player holding a Mojang player's name locks the owner out of their
|
// NAME, so a third-party player holding a Mojang player's name locks the owner out of their
|
||||||
// own proxy ("You are already connected to this proxy!") even though the UUID rewrite makes
|
// own proxy ("You are already connected to this proxy!") even though the UUID rewrite makes
|
||||||
|
|||||||
Reference in new issue
Block a user