Files
scrabble-game/backend/internal/dictadmin/dictadmin_test.go
T
Ilia Denisov 0f3671f42d
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
feat(admin): online dictionary update — upload archive, preview word diff, install & activate
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.
2026-06-13 23:29:06 +02:00

249 lines
7.5 KiB
Go

package dictadmin
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"scrabble/backend/internal/engine"
)
// dawgNames are the three committed dictionary filenames a release archive holds.
func dawgNames() []string {
out := make([]string, 0, 3)
for _, name := range engine.DictFiles() {
out = append(out, name)
}
return out
}
// fullArchive builds a gzip+tar archive holding all three dictionary files with
// dummy content, the shape of a real release archive.
func fullArchive(t *testing.T) []byte {
t.Helper()
files := map[string][]byte{}
for _, n := range dawgNames() {
files[n] = []byte("dawg:" + n)
}
return tarGz(t, files, nil)
}
// tarGz builds a gzip+tar archive. regular maps a member name to its bytes; extra
// lets a test inject crafted headers (symlinks, oversized members, junk entries).
func tarGz(t *testing.T, regular map[string][]byte, extra []*tar.Header) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, data := range regular {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
t.Fatalf("write header %s: %v", name, err)
}
if _, err := tw.Write(data); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
for _, hdr := range extra {
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write extra header %s: %v", hdr.Name, err)
}
if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 {
if _, err := tw.Write(bytes.Repeat([]byte("x"), int(hdr.Size))); err != nil {
t.Fatalf("write extra %s: %v", hdr.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 TestParseVersionFromName(t *testing.T) {
cases := []struct {
name string
want string
wantErr bool
}{
{"scrabble-dawg-v1.2.3.tar.gz", "v1.2.3", false},
{"scrabble-dawg-v10.0.1.tar.gz", "v10.0.1", false},
{"scrabble-dawg-v1.2.tar.gz", "", true},
{"scrabble-dawg-1.2.3.tar.gz", "", true},
{"scrabble-dawg-v1.2.3.zip", "", true},
{"dawg-v1.2.3.tar.gz", "", true},
{"scrabble-dawg-v1.2.3.tar.gz.exe", "", true},
{"", "", true},
}
for _, c := range cases {
got, err := ParseVersionFromName(c.name)
if c.wantErr {
if err == nil {
t.Errorf("ParseVersionFromName(%q) = %q, want error", c.name, got)
}
continue
}
if err != nil || got != c.want {
t.Errorf("ParseVersionFromName(%q) = %q, %v; want %q", c.name, got, err, c.want)
}
}
}
func TestValidVersion(t *testing.T) {
valid := []string{"v1.0.0", "v10.20.30", "v0.0.1"}
invalid := []string{"v1.0", "1.0.0", "v1.0.0.0", "v1.0.0/..", "v1.0.0 ", "", "vx.y.z", "../v1.0.0"}
for _, v := range valid {
if !ValidVersion(v) {
t.Errorf("ValidVersion(%q) = false, want true", v)
}
}
for _, v := range invalid {
if ValidVersion(v) {
t.Errorf("ValidVersion(%q) = true, want false", v)
}
}
}
func TestExtractAcceptsFullArchive(t *testing.T) {
dir := t.TempDir()
variants, err := Extract(bytes.NewReader(fullArchive(t)), dir)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if len(variants) != len(engine.Variants()) {
t.Errorf("variants = %v, want all %d", variants, len(engine.Variants()))
}
for _, n := range dawgNames() {
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
t.Errorf("missing extracted %s: %v", n, err)
}
}
}
func TestExtractIgnoresUnexpectedAndTraversal(t *testing.T) {
dir := t.TempDir()
files := map[string][]byte{
"pkg/en_sowpods.dawg": []byte("ok"), // a subdir path is stripped to its base
"README.txt": []byte("junk"), // an unexpected name is ignored
}
// A traversal name with a recognised base must still land inside destDir.
traversal := &tar.Header{Name: "../ru_scrabble.dawg", Mode: 0o644, Size: 2, Typeflag: tar.TypeReg}
// A symlink, even named like a DAWG, is ignored.
symlink := &tar.Header{Name: "erudit_ru.dawg", Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}
if _, err := Extract(bytes.NewReader(tarGz(t, files, []*tar.Header{traversal, symlink})), dir); err != nil {
t.Fatalf("Extract: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err == nil {
t.Error("README.txt was extracted; unexpected files must be ignored")
}
if _, err := os.Stat(filepath.Join(dir, "en_sowpods.dawg")); err != nil {
t.Errorf("en_sowpods.dawg not extracted from a subdir entry: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "ru_scrabble.dawg")); err != nil {
t.Errorf("ru_scrabble.dawg not extracted from a traversal entry: %v", err)
}
// Nothing escaped the destination directory.
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "ru_scrabble.dawg")); err == nil {
t.Error("path traversal wrote outside the destination directory")
}
if _, err := os.Stat(filepath.Join(dir, "erudit_ru.dawg")); err == nil {
t.Error("a symlink member was materialised")
}
}
func TestExtractRejectsOversizeMember(t *testing.T) {
old := maxMemberBytes
maxMemberBytes = 8
defer func() { maxMemberBytes = old }()
files := map[string][]byte{"en_sowpods.dawg": bytes.Repeat([]byte("x"), 64)}
if _, err := Extract(bytes.NewReader(tarGz(t, files, nil)), t.TempDir()); err == nil {
t.Error("Extract accepted an oversize member, want error")
}
}
func TestExtractRejectsTooManyEntries(t *testing.T) {
var extra []*tar.Header
for i := 0; i < maxEntries+1; i++ {
extra = append(extra, &tar.Header{Name: fmt.Sprintf("junk-%d.bin", i), Mode: 0o644, Size: 1, Typeflag: tar.TypeReg})
}
if _, err := Extract(bytes.NewReader(tarGz(t, nil, extra)), t.TempDir()); err == nil {
t.Error("Extract accepted an archive with too many entries, want error")
}
}
func TestManagerStageInstall(t *testing.T) {
dir := t.TempDir()
m := New(dir)
token, variants, err := m.Stage(bytes.NewReader(fullArchive(t)))
if err != nil {
t.Fatalf("Stage: %v", err)
}
if len(variants) != len(engine.Variants()) {
t.Fatalf("staged variants = %v, want all", variants)
}
staged, err := m.StagedDir(token)
if err != nil {
t.Fatalf("StagedDir: %v", err)
}
if !strings.Contains(staged, filepath.Join(".staging", token)) {
t.Errorf("staged dir %q not under .staging/%s", staged, token)
}
if m.VersionExists("v1.0.0") {
t.Fatal("v1.0.0 should not exist before install")
}
dest, err := m.Install(token, "v1.0.0")
if err != nil {
t.Fatalf("Install: %v", err)
}
if dest != filepath.Join(dir, "v1.0.0") {
t.Errorf("install dest = %q, want %q", dest, filepath.Join(dir, "v1.0.0"))
}
if !m.VersionExists("v1.0.0") {
t.Error("VersionExists(v1.0.0) = false after install")
}
for _, n := range dawgNames() {
if _, err := os.Stat(filepath.Join(dest, n)); err != nil {
t.Errorf("installed %s missing: %v", n, err)
}
}
}
func TestManagerInstallRejectsExistingVersion(t *testing.T) {
dir := t.TempDir()
m := New(dir)
token, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
if err != nil {
t.Fatalf("Stage: %v", err)
}
if _, err := m.Install(token, "v2.0.0"); err != nil {
t.Fatalf("first Install: %v", err)
}
token2, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
if err != nil {
t.Fatalf("second Stage: %v", err)
}
if _, err := m.Install(token2, "v2.0.0"); err == nil {
t.Error("Install overwrote an existing version, want error (versions are immutable)")
}
}
func TestStagedDirRejectsBadToken(t *testing.T) {
m := New(t.TempDir())
for _, bad := range []string{"", "../etc", "ABC", "zz", strings.Repeat("g", 32)} {
if _, err := m.StagedDir(bad); err == nil {
t.Errorf("StagedDir(%q) = nil error, want rejection", bad)
}
}
}