Files
scrabble-game/backend/internal/inttest/admin_test.go
T
Ilia Denisov ab58062565 R3: backend rate-limit observability — ratewatch, auto-flag, admin throttled view
- accounts.flagged_high_rate_at baked into the R1 baseline (no prod data; the
  contour schema is wiped after merge); jet regenerated — the regen also picks
  up the previously missing game_drafts/game_hidden models.
- account.Store: FlagHighRate (set-once), ClearHighRateFlag, the flag in
  GetByID/ListUsers and a ListFlaggedHighRate review queue.
- New internal/ratewatch: ingests the gateway rejection reports, keeps a
  bounded in-memory episode window for the console and applies the
  conservative auto-flag (1000 rejected / 10 min, BACKEND_HIGHRATE_FLAG_*).
- POST /api/v1/internal/ratelimit/report (network-trusted, like
  sessions/resolve).
- Admin console: Throttled page (episodes + flagged accounts), a high-rate
  badge in the user list, the marker + operator clear action on the user card.
- Tests: ratewatch unit suite, report-route handler test, renderer cases,
  integration coverage for the store round-trip and the console flow.
2026-06-10 02:14:10 +02:00

290 lines
11 KiB
Go

//go:build integration
package inttest
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/server"
)
// TestComplaintResolutionPipeline drives a complaint from filing through
// resolution into the dictionary-change pipeline and on to "applied".
func TestComplaintResolutionPipeline(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1})
if err != nil {
t.Fatalf("create: %v", err)
}
word := "zzzzzz" // a non-word the filer thinks should be valid → an accept_add candidate
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], word, "please add")
if err != nil {
t.Fatalf("file: %v", err)
}
if open, _ := svc.CountComplaints(ctx, game.StatusComplaintOpen); open < 1 {
t.Fatalf("open complaints = %d, want >= 1", open)
}
list, err := svc.ListComplaints(ctx, game.StatusComplaintOpen, 100, 0)
if err != nil || !containsComplaint(list, filed.ID) {
t.Fatalf("open list missing filed complaint (err %v)", err)
}
resolved, err := svc.ResolveComplaint(ctx, filed.ID, game.DispositionAcceptAdd, "agreed")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if resolved.Status != game.StatusComplaintResolved || resolved.Disposition != game.DispositionAcceptAdd || resolved.ResolvedAt == nil {
t.Fatalf("unexpected resolved complaint: %+v", resolved)
}
changes, err := svc.DictionaryChanges(ctx)
if err != nil {
t.Fatalf("changes: %v", err)
}
if !changeFor(changes, word, true) {
t.Fatalf("dictionary changes missing add %q: %+v", word, changes)
}
n, err := svc.MarkChangesApplied(ctx, engine.VariantEnglish, "v2")
if err != nil || n < 1 {
t.Fatalf("mark applied n=%d err=%v", n, err)
}
if after, err := svc.DictionaryChanges(ctx); err != nil || changeFor(after, word, true) {
t.Fatalf("change still pending after apply (err %v): %+v", err, after)
}
}
func containsComplaint(list []game.Complaint, id uuid.UUID) bool {
for _, c := range list {
if c.ID == id {
return true
}
}
return false
}
func changeFor(changes []game.DictionaryChange, word string, add bool) bool {
for _, c := range changes {
if c.Word == word && c.Add == add {
return true
}
}
return false
}
// TestAdminListsAndCounts checks the admin read queries and their COUNT scans.
func TestAdminListsAndCounts(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
svc := newGameService()
accBefore, err := store.CountAccounts(ctx)
if err != nil {
t.Fatalf("count accounts: %v", err)
}
a, b := provisionAccount(t), provisionAccount(t)
accAfter, err := store.CountAccounts(ctx)
if err != nil {
t.Fatalf("count accounts: %v", err)
}
if accAfter < accBefore+2 {
t.Errorf("account count did not grow by 2: %d -> %d", accBefore, accAfter)
}
if page, err := store.ListAccounts(ctx, 1, 0); err != nil || len(page) != 1 {
t.Fatalf("list accounts page size 1 = %d (err %v)", len(page), err)
}
if ids, err := store.Identities(ctx, a); err != nil || len(ids) != 1 || ids[0].Kind != account.KindTelegram {
t.Fatalf("identities for a = %+v (err %v)", ids, err)
}
gBefore, _ := svc.CountGames(ctx, "")
if _, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 2}); err != nil {
t.Fatalf("create: %v", err)
}
if gAfter, _ := svc.CountGames(ctx, ""); gAfter != gBefore+1 {
t.Errorf("game count %d -> %d, want +1", gBefore, gAfter)
}
if active, err := svc.ListGames(ctx, game.StatusActive, 100, 0); err != nil || len(active) == 0 {
t.Fatalf("list active games = %d (err %v)", len(active), err)
}
}
// TestConsoleServesAndGuardsCSRF drives the /_gm console over HTTP against real
// stores: pages render, and a state-changing POST needs a same-origin header.
func TestConsoleServesAndGuardsCSRF(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 3})
if err != nil {
t.Fatalf("create: %v", err)
}
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], "qwxz", "review")
if err != nil {
t.Fatalf("file: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(),
Accounts: account.NewStore(testDB),
Games: svc,
Registry: testRegistry,
DictDir: dictDir(),
})
h := srv.Handler()
base := "http://admin.test/_gm"
if code, body := consoleDo(h, http.MethodGet, base+"/", "", ""); code != http.StatusOK || !strings.Contains(body, "Dashboard") {
t.Fatalf("dashboard = %d, has Dashboard=%v", code, strings.Contains(body, "Dashboard"))
}
if code, body := consoleDo(h, http.MethodGet, base+"/complaints/"+filed.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "qwxz") {
t.Fatalf("complaint detail = %d, has word=%v", code, strings.Contains(body, "qwxz"))
}
// A resolve POST without a same-origin header is rejected by the CSRF guard.
if code, _ := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject", ""); code != http.StatusForbidden {
t.Fatalf("resolve without origin = %d, want 403", code)
}
// With a matching Origin it succeeds and persists.
if code, body := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject&note=ok", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Resolved") {
t.Fatalf("resolve with origin = %d, has Resolved=%v", code, strings.Contains(body, "Resolved"))
}
if got, err := svc.GetComplaint(ctx, filed.ID); err != nil || got.Status != game.StatusComplaintResolved {
t.Fatalf("complaint not resolved: %+v (err %v)", got, err)
}
}
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17).
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
human := provisionAccount(t)
// Seat the robot first so it is to move (seat 0), exposing the next-move ETA.
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7,
})
if err != nil {
t.Fatalf("create: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(),
})
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "")
if code != http.StatusOK {
t.Fatalf("game detail = %d, want 200", code)
}
if !strings.Contains(body, "🤖") {
t.Error("robot seat is not marked in the game detail")
}
if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") {
t.Error("robot play-to-win intent missing")
}
if !strings.Contains(body, "next move") {
t.Error("robot is to move but the next-move ETA is missing")
}
if !strings.Contains(body, "~40%") {
t.Error("robot play-to-win target caption missing")
}
}
// TestConsoleThrottledViewAndFlagClear drives the R3 rate-limit surface end to
// end against real stores: a gateway report past the threshold auto-flags the
// account, the throttled view shows the episode and the flagged account, the
// user card carries the marker, and the operator clear (a same-origin POST)
// reverses it.
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
if err != nil {
t.Fatalf("provision: %v", err)
}
watch := ratewatch.New(ratewatch.Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, accounts, zap.NewNop())
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(),
Accounts: accounts,
Games: newGameService(),
Registry: testRegistry,
DictDir: dictDir(),
RateWatch: watch,
})
h := srv.Handler()
report := `{"window_seconds":30,"entries":[` +
`{"class":"user","key":"` + acc.ID.String() + `","rejected":150},` +
`{"class":"public","key":"10.1.2.3","rejected":7}]}`
req := httptest.NewRequest(http.MethodPost, "http://admin.test/api/v1/internal/ratelimit/report", strings.NewReader(report))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("report = %d, want 204", rec.Code)
}
got, err := accounts.GetByID(ctx, acc.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.FlaggedHighRateAt.IsZero() {
t.Fatal("account not auto-flagged past the threshold")
}
base := "http://admin.test/_gm"
code, body := consoleDo(h, http.MethodGet, base+"/throttled", "", "")
if code != http.StatusOK || !strings.Contains(body, acc.ID.String()) ||
!strings.Contains(body, "10.1.2.3") || !strings.Contains(body, "Throttled Player") {
t.Fatalf("throttled view = %d, episode/flag shown = %v/%v",
code, strings.Contains(body, "10.1.2.3"), strings.Contains(body, "Throttled Player"))
}
if code, body = consoleDo(h, http.MethodGet, base+"/users/"+acc.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "Clear high-rate flag") {
t.Fatalf("user card = %d, has clear action = %v", code, strings.Contains(body, "Clear high-rate flag"))
}
// The clear POST is CSRF-guarded like every console action.
if code, _ = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "", ""); code != http.StatusForbidden {
t.Fatalf("clear without origin = %d, want 403", code)
}
if code, body = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "x=1", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Cleared") {
t.Fatalf("clear with origin = %d, has Cleared = %v", code, strings.Contains(body, "Cleared"))
}
if got, err = accounts.GetByID(ctx, acc.ID); err != nil || !got.FlaggedHighRateAt.IsZero() {
t.Fatalf("flag survived the clear: %v (err %v)", got.FlaggedHighRateAt, err)
}
}
// consoleDo issues a request to h, optionally with an Origin header, and returns
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
req := httptest.NewRequest(method, target, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if origin != "" {
req.Header.Set("Origin", origin)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}