//go:build integration package inttest import ( "archive/tar" "bytes" "compress/gzip" "context" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "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/server" ) // TestDictionaryUpdateFlow drives the admin dictionary update end to end over HTTP // against real stores and an isolated dictionary directory: upload a release // archive, preview the diff, install it, and confirm it becomes the active version, // is written to disk, survives a restart (a fresh service re-adopts it), pins new // games while leaving an in-progress game on its old version, and is immutable. func TestDictionaryUpdateFlow(t *testing.T) { ctx := context.Background() // Isolated dictionary directory seeded with the real DAWGs as the flat boot // version v1.0.0, with its own registry and service so the shared fixtures are // untouched. dir := t.TempDir() seedDawgs(t, dictDir(), dir) reg, err := engine.OpenWithVersions(dir, "v1.0.0") if err != nil { t.Fatalf("open registry: %v", err) } defer func() { _ = reg.Close() }() svc := newGameServiceOn(dir, "v1.0.0", reg) if err := svc.InitActiveVersion(ctx); err != nil { t.Fatalf("init active version: %v", err) } if got := svc.ActiveVersion(); got != "v1.0.0" { t.Fatalf("active version = %q, want v1.0.0", got) } // An in-progress game pinned to the original version. seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} oldGame, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1}) if err != nil { t.Fatalf("create old game: %v", err) } if oldGame.DictVersion != "v1.0.0" { t.Fatalf("old game dict_version = %q, want v1.0.0", oldGame.DictVersion) } srv := server.New(":0", server.Deps{ Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: reg, DictDir: dir, }) h := srv.Handler() base := "http://admin.test/_gm" archive := releaseArchive(t, dir) const fileName = "scrabble-dawg-v9.9.9.tar.gz" // Upload without a same-origin header is rejected by the CSRF guard. if code, _ := consoleUpload(h, base+"/dictionary/upload", fileName, archive, ""); code != http.StatusForbidden { t.Fatalf("upload without origin = %d, want 403", code) } // Upload with origin renders the preview carrying the version and a token. code, body := consoleUpload(h, base+"/dictionary/upload", fileName, archive, "http://admin.test") if code != http.StatusOK || !strings.Contains(body, "v9.9.9") { t.Fatalf("upload = %d, has version=%v", code, strings.Contains(body, "v9.9.9")) } token := tokenFrom(t, body) // Confirm installs, loads and activates the new version. code, body = consoleDo(h, http.MethodPost, base+"/dictionary/install", "token="+token+"&version=v9.9.9", "http://admin.test") if code != http.StatusOK || !strings.Contains(body, "Updated") { t.Fatalf("install = %d, has Updated=%v", code, strings.Contains(body, "Updated")) } // The new version is active, resident for every variant, and written to disk. if got := svc.ActiveVersion(); got != "v9.9.9" { t.Fatalf("active version after install = %q, want v9.9.9", got) } for _, v := range engine.Variants() { if _, err := reg.Solver(v, "v9.9.9"); err != nil { t.Errorf("variant %s v9.9.9 not resident: %v", v, err) } } if _, err := os.Stat(filepath.Join(dir, "v9.9.9", "en_sowpods.dawg")); err != nil { t.Errorf("installed dawg missing on disk: %v", err) } // The choice survives a restart: a fresh service over the same DB and registry // re-adopts the persisted active version. restarted := newGameServiceOn(dir, "v1.0.0", reg) if err := restarted.InitActiveVersion(ctx); err != nil { t.Fatalf("restart init: %v", err) } if got := restarted.ActiveVersion(); got != "v9.9.9" { t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got) } // New games pin the new version; the in-progress game keeps its own, still resident. newGame, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{provisionAccount(t), provisionAccount(t)}, TurnTimeout: 24 * time.Hour, Seed: 2}) if err != nil { t.Fatalf("create new game: %v", err) } if newGame.DictVersion != "v9.9.9" { t.Errorf("new game dict_version = %q, want v9.9.9", newGame.DictVersion) } if _, err := reg.Solver(engine.VariantEnglish, oldGame.DictVersion); err != nil { t.Errorf("old game's version %s no longer resident: %v", oldGame.DictVersion, err) } // Re-uploading the now-resident version is rejected (versions are immutable). if _, body := consoleUpload(h, base+"/dictionary/upload", fileName, archive, "http://admin.test"); !strings.Contains(body, "Already resident") { t.Errorf("re-upload of resident version not rejected: %q", body) } } // newGameServiceOn builds a game service over the shared pool but a caller-supplied // dictionary directory, version and registry, for the isolated dictionary tests. func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service { svc := game.NewService( game.NewStore(testDB), account.NewStore(testDB), reg, game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour}, zap.NewNop(), ) svc.SetFirstMoveEntropy(seatZeroFirstMove) return svc } // seedDawgs copies the committed DAWG of every variant from src into dst (flat). func seedDawgs(t *testing.T, src, dst string) { t.Helper() for _, name := range engine.DictFiles() { copyFile(t, filepath.Join(src, name), filepath.Join(dst, name)) } } // releaseArchive packs the three DAWGs from dir into an in-memory gzip+tar release // archive, the shape consoleUploadDictionary expects. func releaseArchive(t *testing.T, dir string) []byte { t.Helper() var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) for _, name := range engine.DictFiles() { data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Fatalf("read %s: %v", name, err) } if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil { t.Fatalf("tar header %s: %v", name, err) } if _, err := tw.Write(data); err != nil { t.Fatalf("tar write %s: %v", name, err) } } if err := tw.Close(); err != nil { t.Fatalf("close tar: %v", err) } if err := gz.Close(); err != nil { t.Fatalf("close gzip: %v", err) } return buf.Bytes() } func copyFile(t *testing.T, src, dst string) { t.Helper() data, err := os.ReadFile(src) if err != nil { t.Fatalf("read %s: %v", src, err) } if err := os.WriteFile(dst, data, 0o644); err != nil { t.Fatalf("write %s: %v", dst, err) } } var tokenRE = regexp.MustCompile(`name="token" value="([0-9a-f]{32})"`) // tokenFrom extracts the staging token from a rendered preview page. func tokenFrom(t *testing.T, body string) string { t.Helper() m := tokenRE.FindStringSubmatch(body) if m == nil { t.Fatalf("no staging token in preview body:\n%s", body) } return m[1] } // consoleUpload posts a multipart archive to target as the "archive" file field, // optionally with an Origin header, returning the status and body. func consoleUpload(h http.Handler, target, filename string, data []byte, origin string) (int, string) { var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("archive", filename) _, _ = io.Copy(fw, bytes.NewReader(data)) _ = mw.Close() req := httptest.NewRequest(http.MethodPost, target, &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) if origin != "" { req.Header.Set("Origin", origin) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec.Code, rec.Body.String() }