ab58062565
- 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.
290 lines
11 KiB
Go
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¬e=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()
|
|
}
|