fix: code quality, memory safety, and install improvements

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
This commit is contained in:
MrFastwind
2026-07-25 02:47:04 +02:00
parent 8bf163a931
commit 4d6f162b8f
18 changed files with 638 additions and 88 deletions
@@ -0,0 +1,209 @@
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)
}
}