Files
pzmanager/internal/backup/backup_test.go
T
mrleo1nidandClaude Opus 5 277ab7af4a Профили серверов: несколько миров с раздельными настройками
Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и
лимит памяти JVM. Project Zomboid различает их по -servername, поэтому
файлы профилей не пересекаются. Работает всегда один профиль: серверные
файлы, игровые порты и файл запуска JVM общие, так что переключение
разрешено только на остановленном сервере.

- config: список профилей вместо одиночных server_name/admin_password/
  java_memory; старые поля переносятся в первый профиль при загрузке;
- supervisor: запускает активный профиль и выставляет его -Xmx на каждом
  старте, потому что файл запуска общий;
- бэкапы: архив снимается с одного профиля (мир + его конфиги), имя
  профиля попадает в имя файла, ротация считается по профилям;
- API профилей: список, создание с копированием настроек из другого
  профиля, правка, удаление и переключение активного;
- панель: селектор профиля в шапке и вкладка со списком профилей.

Удаление профиля убирает его только из панели: мир и конфиги остаются на
диске, чтобы случайное нажатие не стоило сохранений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:27:12 +03:00

225 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"os"
"path/filepath"
"testing"
"github.com/mrleo1nid/pzmanager/internal/config"
)
func TestSafeJoinRejectsTraversal(t *testing.T) {
root := filepath.Join(os.TempDir(), "pzm-root")
for _, name := range []string{"../evil", "../../etc/passwd", "Saves/../../evil"} {
if _, err := safeJoin(root, name); err == nil {
t.Errorf("путь %q принят, ожидался отказ", name)
}
}
if _, err := safeJoin(root, "Saves/Multiplayer/map.bin"); err != nil {
t.Errorf("обычный путь отклонён: %v", err)
}
}
func TestSanitizeNote(t *testing.T) {
cases := map[string]string{
"перед вайпом": "",
"before wipe": "before_wipe",
"v1.2/../x": "v12x",
"": "",
"__trim-me__": "trim-me",
}
for input, want := range cases {
if got := sanitizeNote(input); got != want {
t.Errorf("sanitizeNote(%q) = %q, ожидалось %q", input, got, want)
}
}
}
func TestNameValidationAndProfileParsing(t *testing.T) {
m := New(&config.Config{})
if _, err := m.Path("../../etc/passwd"); err == nil {
t.Error("имя с переходом по каталогам принято")
}
if _, err := m.Path("pz-vanilla-20260904-181500-auto.tar.gz"); err != nil {
t.Errorf("корректное имя отклонено: %v", err)
}
// Архивы, снятые до появления профилей, тоже должны читаться.
if _, err := m.Path("pz-20250903-214141.tar.gz"); err != nil {
t.Errorf("архив старого формата отклонён: %v", err)
}
cases := map[string]string{
"pz-vanilla-20260904-181500-auto.tar.gz": "vanilla",
"pz-modded_b42-20260904-181500.tar.gz": "modded_b42",
"pz-20250903-214141.tar.gz": "",
"не-архив.tar.gz": "",
}
for name, want := range cases {
if got := m.ProfileOf(name); got != want {
t.Errorf("ProfileOf(%q) = %q, ожидалось %q", name, got, want)
}
}
}
// TestProfileConfigFiles проверяет, что профиль забирает только свои файлы:
// префиксы имён профилей часто пересекаются.
func TestProfileConfigFiles(t *testing.T) {
dir := t.TempDir()
files := []string{
"test.ini", "test_SandboxVars.lua", "test_spawnpoints.lua",
"test2.ini", "test2_SandboxVars.lua",
"testing.ini", "test.ini.bak", "other.ini",
}
for _, name := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := profileConfigFiles(dir, "test")
if err != nil {
t.Fatal(err)
}
want := map[string]bool{
"test.ini": true, "test_SandboxVars.lua": true, "test_spawnpoints.lua": true,
}
if len(got) != len(want) {
t.Fatalf("выбрано %d файлов, ожидалось %d: %v", len(got), len(want), got)
}
for _, path := range got {
if !want[filepath.Base(path)] {
t.Errorf("лишний файл в архиве профиля: %s", filepath.Base(path))
}
}
}
// TestCreateAndRestore проверяет полный цикл для одного профиля: архивируем
// мир и конфиг, портим их, восстанавливаем и убеждаемся, что вернулось
// содержимое именно этого профиля.
func TestCreateAndRestore(t *testing.T) {
root := t.TempDir()
zomboid := filepath.Join(root, "Zomboid")
world := filepath.Join(zomboid, "Saves", "Multiplayer", "vanilla", "map_t.bin")
iniPath := filepath.Join(zomboid, "Server", "vanilla.ini")
otherWorld := filepath.Join(zomboid, "Saves", "Multiplayer", "modded", "map_t.bin")
writeFile(t, world, "исходный мир")
writeFile(t, iniPath, "MaxPlayers=32")
writeFile(t, otherWorld, "чужой мир")
cfg := &config.Config{ZomboidDir: zomboid}
cfg.Backup.Dir = filepath.Join(root, "backups")
m := New(cfg)
archive, err := m.Create(context.Background(), func(string) {}, "vanilla", "test")
if err != nil {
t.Fatalf("Create: %v", err)
}
if archive.Profile != "vanilla" {
t.Errorf("профиль архива = %q", archive.Profile)
}
writeFile(t, world, "испорчено")
writeFile(t, iniPath, "MaxPlayers=1")
writeFile(t, otherWorld, "чужой мир изменён")
if err := m.Restore(context.Background(), func(string) {}, archive.Name); err != nil {
t.Fatalf("Restore: %v", err)
}
if got := readFile(t, world); got != "исходный мир" {
t.Errorf("мир после восстановления = %q", got)
}
if got := readFile(t, iniPath); got != "MaxPlayers=32" {
t.Errorf("конфиг после восстановления = %q", got)
}
// Мир другого профиля восстановление трогать не должно.
if got := readFile(t, otherWorld); got != "чужой мир изменён" {
t.Errorf("затронут мир чужого профиля: %q", got)
}
}
// TestPruneIsPerProfile проверяет, что ротация не выкидывает архивы соседних
// профилей: у каждого профиля своя история.
func TestPruneIsPerProfile(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{ZomboidDir: filepath.Join(root, "Zomboid")}
cfg.Backup.Dir = filepath.Join(root, "backups")
m := New(cfg)
if err := os.MkdirAll(cfg.Backup.Dir, 0o755); err != nil {
t.Fatal(err)
}
names := []string{
"pz-vanilla-20260101-000000.tar.gz",
"pz-vanilla-20260102-000000.tar.gz",
"pz-vanilla-20260103-000000.tar.gz",
"pz-modded-20260101-000000.tar.gz",
}
for _, name := range names {
writeEmptyArchive(t, filepath.Join(cfg.Backup.Dir, name))
}
if err := m.Prune("vanilla", 2, func(string) {}); err != nil {
t.Fatal(err)
}
vanilla, err := m.ListForProfile("vanilla")
if err != nil {
t.Fatal(err)
}
if len(vanilla) != 2 {
t.Errorf("у vanilla осталось %d архивов, ожидалось 2", len(vanilla))
}
modded, err := m.ListForProfile("modded")
if err != nil {
t.Fatal(err)
}
if len(modded) != 1 {
t.Errorf("ротация зацепила архивы профиля modded: осталось %d", len(modded))
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
// writeEmptyArchive создаёт валидный пустой tar.gz — List проверяет имя, а не
// содержимое, но пусть файлы будут настоящими.
func writeEmptyArchive(t *testing.T, path string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
}