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