Critical fixes: - Fix Dockerfile: reorder stages so frontend assets embed into Go binary - Fix Go version 1.25 (nonexistent) to 1.24 across Dockerfile, go.mod, CI - Add graceful game server shutdown on SIGTERM/SIGINT - Order startup tasks: updates complete before auto-start - Fix TOCTOU race in UpdateSettings with atomic Update() method Security: - Add optional AUTH_TOKEN bearer auth middleware on API/WS routes - Fix path traversal in DeleteMod using filepath.Rel instead of HasPrefix - Add input validation for IPPort, ServerParameters, ScheduledUpdate Memory safety: - Cap RPT buffer allocation to 64KB to prevent OOM on large logs - Cap GetLog file read to 10MB - Fix context cancel leak in SteamCmdManager.run() - Remove data-raced cancel field in steamcmd.go - Atomic file writes (write-temp-then-rename) across all managers Reliability: - Log save errors in ProcessManager.Stop() - Atomic file writes prevent corruption on crash Tests: - Add mod_manager_test.go (12 tests: ListWorkshopMods, ListLocalMods, BuildUsageMap, RemoveMod, dirSize) - Add scheduler_test.go (6 tests: Start/Stop, Refresh with empty, invalid, valid, and replaced cron expressions) - Add TestRestart to server_process_test.go CI/Docs: - Add -race flag to go test in CI and Makefile - Add npm lint step to CI - Add Go/npm module caching to CI - Update README: prerequisites, AUTH_TOKEN/GIN_MODE/SERVERS_DIR docs, fix manual quickstart to use make build
210 lines
5.0 KiB
Go
210 lines
5.0 KiB
Go
package services
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"arma3-web-server/internal/models"
|
|
)
|
|
|
|
func TestListWorkshopMods_Empty(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mods := ListWorkshopMods(dir)
|
|
if len(mods) != 0 {
|
|
t.Fatalf("expected 0 mods, got %d", len(mods))
|
|
}
|
|
}
|
|
|
|
func TestListWorkshopMods_WithMods(t *testing.T) {
|
|
dir := t.TempDir()
|
|
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
|
|
if err := os.MkdirAll(filepath.Join(workshopDir, "123456"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(workshopDir, "789012"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mods := ListWorkshopMods(dir)
|
|
if len(mods) != 2 {
|
|
t.Fatalf("expected 2 mods, got %d", len(mods))
|
|
}
|
|
|
|
ids := map[string]bool{}
|
|
for _, m := range mods {
|
|
ids[m.ID] = true
|
|
if m.Source != "workshop" {
|
|
t.Errorf("expected source 'workshop', got %q", m.Source)
|
|
}
|
|
}
|
|
if !ids["123456"] || !ids["789012"] {
|
|
t.Errorf("expected both mod IDs, got %v", ids)
|
|
}
|
|
}
|
|
|
|
func TestListWorkshopMods_SkipsFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410")
|
|
if err := os.MkdirAll(workshopDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(workshopDir, "notadir.txt"), []byte("hi"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(workshopDir, "111111"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mods := ListWorkshopMods(dir)
|
|
if len(mods) != 1 {
|
|
t.Fatalf("expected 1 mod, got %d", len(mods))
|
|
}
|
|
if mods[0].ID != "111111" {
|
|
t.Errorf("expected ID '111111', got %q", mods[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestListLocalMods_Empty(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mods := ListLocalMods(dir)
|
|
if len(mods) != 0 {
|
|
t.Fatalf("expected 0 mods, got %d", len(mods))
|
|
}
|
|
}
|
|
|
|
func TestListLocalMods_WithMods(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "@ACE3"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(dir, "@TFAR"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mods := ListLocalMods(dir)
|
|
if len(mods) != 2 {
|
|
t.Fatalf("expected 2 mods, got %d", len(mods))
|
|
}
|
|
|
|
for _, m := range mods {
|
|
if m.Source != "local" {
|
|
t.Errorf("expected source 'local', got %q", m.Source)
|
|
}
|
|
if len(m.Name) > 0 && m.Name[0] != '@' {
|
|
t.Errorf("expected name to start with '@', got %q", m.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListLocalMods_SkipsFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hi"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(dir, "@Mod"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mods := ListLocalMods(dir)
|
|
if len(mods) != 1 {
|
|
t.Fatalf("expected 1 mod, got %d", len(mods))
|
|
}
|
|
}
|
|
|
|
func TestBuildUsageMap(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
mm := NewModlistManager(dataDir)
|
|
|
|
ml, err := mm.Create("Test List")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ml, err = mm.Update(ml.ID, ml.Name, []models.ModEntry{
|
|
{ID: "111111", Name: "ACE3", Enabled: true},
|
|
{ID: "222222", Name: "", Enabled: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = ml
|
|
|
|
usedWorkshop, usedLocal := BuildUsageMap(mm)
|
|
|
|
if lists, ok := usedWorkshop["111111"]; !ok || len(lists) == 0 {
|
|
t.Errorf("expected workshop mod 111111 to be in use")
|
|
}
|
|
if _, ok := usedWorkshop["222222"]; !ok {
|
|
t.Errorf("expected workshop mod 222222 to be in use")
|
|
}
|
|
if _, ok := usedLocal["ACE3"]; !ok {
|
|
t.Errorf("expected local mod ACE3 to be in use")
|
|
}
|
|
if _, ok := usedLocal["@ACE3"]; !ok {
|
|
t.Errorf("expected local mod @ACE3 to be in use")
|
|
}
|
|
}
|
|
|
|
func TestBuildUsageMap_Empty(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
mm := NewModlistManager(dataDir)
|
|
|
|
usedWorkshop, usedLocal := BuildUsageMap(mm)
|
|
if len(usedWorkshop) != 0 || len(usedLocal) != 0 {
|
|
t.Errorf("expected empty maps, got workshop=%v local=%v", usedWorkshop, usedLocal)
|
|
}
|
|
}
|
|
|
|
func TestRemoveMod(t *testing.T) {
|
|
dir := t.TempDir()
|
|
modDir := filepath.Join(dir, "@TestMod")
|
|
if err := os.MkdirAll(modDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(modDir, "config.cpp"), []byte("x"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := RemoveMod(modDir); err != nil {
|
|
t.Fatalf("RemoveMod failed: %v", err)
|
|
}
|
|
if _, err := os.Stat(modDir); !os.IsNotExist(err) {
|
|
t.Errorf("expected directory to be removed")
|
|
}
|
|
}
|
|
|
|
func TestRemoveMod_Nonexistent(t *testing.T) {
|
|
err := RemoveMod("/nonexistent/path/that/does/not/exist")
|
|
if err != nil {
|
|
t.Errorf("RemoveMod on nonexistent path should not error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDirSize(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sub := filepath.Join(dir, "sub")
|
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("world!"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
size := dirSize(dir)
|
|
if size != 11 {
|
|
t.Errorf("expected size 11, got %d", size)
|
|
}
|
|
}
|
|
|
|
func TestDirSize_Empty(t *testing.T) {
|
|
dir := t.TempDir()
|
|
size := dirSize(dir)
|
|
if size != 0 {
|
|
t.Errorf("expected size 0, got %d", size)
|
|
}
|
|
}
|