//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. 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 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() }