aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/rebuild_after_undo_regression_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/rebuild_after_undo_regression_test.go')
-rw-r--r--internal/admin/rebuild_after_undo_regression_test.go88
1 files changed, 88 insertions, 0 deletions
diff --git a/internal/admin/rebuild_after_undo_regression_test.go b/internal/admin/rebuild_after_undo_regression_test.go
new file mode 100644
index 0000000..134b281
--- /dev/null
+++ b/internal/admin/rebuild_after_undo_regression_test.go
@@ -0,0 +1,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)
+ }
+}