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:
flyemoji committed 2026-09-22 13:34:43 +09:00
1 parent 3f7274d29f
commit 942e9a5ff8
1 file changed
+77
+77
View File
@@ -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