From fea2cc6839ec3ee17940a2dea69622dc6f4deabf Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 16 Sep 2026 21:35:55 -0400 Subject: fix: unify crafting ingredient resolution; fix superheat alternatives, quantity gates, and dropped combine byproducts --- .../admin/rebuild_after_undo_regression_test.go | 88 ++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 internal/admin/rebuild_after_undo_regression_test.go (limited to 'internal/admin/rebuild_after_undo_regression_test.go') 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) + } +} -- cgit v1.2.3