aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/rebuild_after_undo_regression_test.go
blob: 134b2819dfe00c3555bf1bf7298c5e4dfda79556 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package admin

// Regressions for the editor's undo/redo and course-attach paths: file paths
// built from request bodies must stay inside the data directory, and every
// disk mutation must refresh the matching runtime state (course index,
// casino games) or gameplay keeps serving stale data.

import (
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// TestCourseAttachRejectsTraversalCourseID: course_id joins into a file path
// under data/courses, so a traversal id ("../../outside") must be rejected
// instead of writing course YAML outside the data directory.
func TestCourseAttachRejectsTraversalCourseID(t *testing.T) {
	root := t.TempDir()
	dir := filepath.Join(root, "data")
	if err := os.MkdirAll(filepath.Join(dir, "courses"), 0o755); err != nil {
		t.Fatal(err)
	}
	target := filepath.Join(root, "outside.yaml")
	original := "name: Outside\n"
	if err := os.WriteFile(target, []byte(original), 0o644); err != nil {
		t.Fatal(err)
	}
	s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}

	rr := httptest.NewRecorder()
	s.handleRoomAttachCourse(rr, httptest.NewRequest("POST", "/api/rooms/1/attach-course",
		strings.NewReader(`{"course_id":"../../outside","after_index":-1}`)), 1)

	if got, err := os.ReadFile(target); err != nil || string(got) != original {
		t.Fatalf("path traversal modified a file outside the data directory; response=%s file=%q err=%v", rr.Body.String(), got, err)
	}
}

// TestUndoOfCourseChangeReloadsRuntimeCourses: undoing a course edit restores
// the on-disk YAML, so the runtime course index must reload like the course
// API handlers do — otherwise gameplay keeps traversing the stale course.
func TestUndoOfCourseChangeReloadsRuntimeCourses(t *testing.T) {
	s := newTestAdminServer(t, map[int]string{1: "id: 1\nname: Room\n"})
	if err := os.MkdirAll(filepath.Join(s.dataDir, "courses"), 0o755); err != nil {
		t.Fatal(err)
	}
	reloads := 0
	s.reloadCourses = func() { reloads++ }

	s.handleCourses(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/courses",
		strings.NewReader(`{"id":"course","name":"Course","obstacles":[{"room_id":1,"verb":"climb"}]}`)))
	if reloads != 1 {
		t.Fatalf("course creation reload count: %d, want 1", reloads)
	}

	s.doUndo(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/undo/undo", nil))
	if _, err := os.Stat(s.courseFilePath("course")); !os.IsNotExist(err) {
		t.Fatal("undo did not delete the course file")
	}
	if reloads != 2 {
		t.Fatalf("undo restored the disk but did not reload courses: got %d callbacks, want 2", reloads)
	}
}

// TestUndoOfRoomCreateEvictsCasinoGames: undoing a room creation deletes the
// room file, so its cached casino games must be evicted (and their outstanding
// wagers refunded through the wired callback) exactly like the direct-delete
// API does. Skipping eviction strands seated players' wagers in games whose
// room no longer exists.
func TestUndoOfRoomCreateEvictsCasinoGames(t *testing.T) {
	content := "id: 1\nname: Room\n"
	s := newTestAdminServer(t, map[int]string{1: content})
	evictions := 0
	s.evictRoom = func(id int) { evictions++ }
	path, _ := s.world.GetRoomPath(1)

	s.undoStack.Push(ChangeDesc{Description: "create room", FilePath: path, NewContent: []byte(content), IsCreate: true})
	s.doUndo(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/undo/undo", nil))

	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Fatal("undo did not delete the room")
	}
	if evictions != 1 {
		t.Fatalf("undo removed the room without evicting/refunding its casino games: got %d callbacks, want 1", evictions)
	}
}