package server import ( "net/http" "strconv" "strings" "testing" "time" "github.com/google/uuid" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/session" ) // newExportServer is the routing harness with a signing key installed, so the // pre-service branches of the export endpoints are reachable. func newExportServer() *Server { return New(":0", Deps{ Sessions: &session.Service{}, Accounts: &account.Store{}, Games: &game.Service{}, ExportSignKey: "test-key", }) } func TestSignExportIsDeterministicAndTamperEvident(t *testing.T) { s := newExportServer() sig := s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен") if sig != s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен") { t.Fatal("same inputs produced different signatures") } for _, tampered := range []string{ s.signExport("g2", "png", "123", "ru", "Europe/Moscow", "пас,обмен"), s.signExport("g1", "gcg", "123", "ru", "Europe/Moscow", "пас,обмен"), s.signExport("g1", "png", "124", "ru", "Europe/Moscow", "пас,обмен"), s.signExport("g1", "png", "123", "en", "Europe/Moscow", "пас,обмен"), s.signExport("g1", "png", "123", "ru", "UTC", "пас,обмен"), s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас"), } { if tampered == sig { t.Fatal("tampered input produced the same signature") } } } func TestExportURLWithoutKeyIs503(t *testing.T) { s := New(":0", Deps{Sessions: &session.Service{}, Accounts: &account.Store{}, Games: &game.Service{}}) rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+uuid.NewString()+"/export-url?kind=png", "", map[string]string{"X-User-ID": uuid.NewString()}) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("mint without key = %d, want 503", rec.Code) } } func TestExportURLRejectsBadParams(t *testing.T) { s := newExportServer() uid := map[string]string{"X-User-ID": uuid.NewString()} gid := uuid.NewString() if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=exe", "", uid); rec.Code != http.StatusBadRequest { t.Fatalf("bad kind = %d, want 400", rec.Code) } long := strings.Repeat("x", maxLabelsLen+1) if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=png&t="+long, "", uid); rec.Code != http.StatusBadRequest { t.Fatalf("oversized labels = %d, want 400", rec.Code) } } func TestExportDownloadRejectsBadSignatures(t *testing.T) { s := newExportServer() gid := uuid.NewString() exp := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10) // A wrong signature never reaches the domain (404 before any service call). url := "/api/v1/public/dl/" + gid + "/png?e=" + exp + "&s=forged" if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound { t.Fatalf("forged signature = %d, want 404", rec.Code) } // A valid signature over an EXPIRED timestamp is refused the same way. old := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10) url = "/api/v1/public/dl/" + gid + "/png?e=" + old + "&s=" + s.signExport(gid, "png", old, "", "", "") if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound { t.Fatalf("expired link = %d, want 404", rec.Code) } // An unknown kind is refused before signature checks. url = "/api/v1/public/dl/" + gid + "/exe?e=" + exp + "&s=x" if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound { t.Fatalf("bad kind = %d, want 404", rec.Code) } } func TestRenderPayloadMapsTheExportView(t *testing.T) { gid := uuid.New() finished := time.Unix(1_782_997_629, 0) g := game.Game{ ID: gid, Variant: engine.VariantRussianScrabble, Status: game.StatusFinished, Players: 2, FinishedAt: &finished, Seats: []game.Seat{ {Seat: 0, Score: 42, IsWinner: true, DisplayName: "snapshot"}, {Seat: 1, Score: 30}, }, } moves := []game.HistoryMove{ { Seat: 0, Action: "play", Dir: "H", MainRow: 7, MainCol: 4, Tiles: []engine.TileRecord{{Row: 7, Col: 4, Letter: "ч", Blank: false}}, Words: []string{"чика"}, Score: 9, RunningTotal: 9, }, {Seat: 1, Action: "exchange", Exchanged: []string{"а", "б"}, RunningTotal: 0}, } names := []string{"Аня", "Боб"} p, err := renderPayload(g, moves, names, "ru", "Europe/Moscow", "пас,обмен,сдался,таймаут", "erudit-game.ru") if err != nil { t.Fatalf("renderPayload: %v", err) } if p.Game.LastActivityUnix != finished.Unix() { t.Fatalf("lastActivityUnix = %d, want %d", p.Game.LastActivityUnix, finished.Unix()) } if p.Game.Seats[0].DisplayName != "Аня" || !p.Game.Seats[0].IsWinner { t.Fatalf("seat 0 = %+v, want the resolved name and the winner flag", p.Game.Seats[0]) } if p.Moves[0].Words[0] != "ЧИКА" || p.Moves[0].Tiles[0].Letter != "Ч" { t.Fatalf("letters not upper-cased: %+v", p.Moves[0]) } if p.Moves[1].Count != 2 { t.Fatalf("exchange count = %d, want 2", p.Moves[1].Count) } if p.Labels["pass"] != "пас" || p.Labels["timeout"] != "таймаут" { t.Fatalf("labels not mapped positionally: %v", p.Labels) } if len(p.Alphabet) == 0 || p.Hostname != "erudit-game.ru" || p.TimeZone != "Europe/Moscow" { t.Fatalf("alphabet/hostname/zone missing: %d entries, host %q, tz %q", len(p.Alphabet), p.Hostname, p.TimeZone) } for _, a := range p.Alphabet { if a.Letter != strings.ToUpper(a.Letter) { t.Fatalf("alphabet letter %q not upper-cased", a.Letter) } } }