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)
}
}
|