feat(admin): online dictionary update — upload archive, preview word diff, install & activate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
Replace the dictionary hot-reload with an online update flow in the GM console (/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the per-variant words added/removed against the active dictionary, and confirms to install + activate it. Versions are immutable; in-progress games keep their pinned version while new games use the new one. - engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder, Registry.Finder, DictFiles; OpenWithVersions skips the .staging area. - dictadmin: hardened release-archive validation + extraction (path-traversal, symlink, oversize, entry-count rejection) and staging -> install (atomic rename). - game: active dictionary version persisted in the dictionary_state singleton (single source of truth, restored on boot), concurrency-safe accessor. - storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned), so uploaded versions persist across redeploys; the build's DICT_VERSION labels the seed and equals the resident tag (BACKEND_DICT_VERSION). - docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE. Tests: engine + dictadmin unit; integration upload->preview->install->activate-> restart->pin->immutability->CSRF.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
//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 {
|
||||
return game.NewService(
|
||||
game.NewStore(testDB), account.NewStore(testDB), reg,
|
||||
game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour},
|
||||
zap.NewNop(),
|
||||
)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user