diff options
| author | historia <[not public]> | 2026-09-16 21:35:55 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 21:35:55 -0400 |
| commit | fea2cc6839ec3ee17940a2dea69622dc6f4deabf (patch) | |
| tree | 9d893525ca20d2cc920e9e866f84f71b605d8ae9 /internal/admin | |
| parent | 35ddd0ad2215760bb1d3dc7724480f179ea33fa6 (diff) | |
| download | thehouseoficarus-fea2cc6839ec3ee17940a2dea69622dc6f4deabf.tar.gz | |
fix: unify crafting ingredient resolution; fix superheat alternatives, quantity gates, and dropped combine byproducts
Diffstat (limited to 'internal/admin')
| -rw-r--r-- | internal/admin/api_room_courses.go | 7 | ||||
| -rw-r--r-- | internal/admin/live_owner_test.go | 3 | ||||
| -rw-r--r-- | internal/admin/rebuild_after_undo_regression_test.go | 88 | ||||
| -rw-r--r-- | internal/admin/server.go | 18 |
4 files changed, 115 insertions, 1 deletions
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go index bebacff..5a33e6a 100644 --- a/internal/admin/api_room_courses.go +++ b/internal/admin/api_room_courses.go @@ -321,6 +321,13 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ failJSON(w, "missing course_id") return } + // Course IDs join into file paths under data/courses, so an unchecked + // id ("../../x") could write course YAML outside the data directory. + // Same rule as every other entity route (see validEntityID). + if !validEntityID(body.CourseID) { + failJSON(w, "invalid course_id") + return + } m, _, path, err := s.loadCourseMap(body.CourseID) if err != nil { failJSON(w, "course not found: "+body.CourseID) diff --git a/internal/admin/live_owner_test.go b/internal/admin/live_owner_test.go index 7e94a1e..e975681 100644 --- a/internal/admin/live_owner_test.go +++ b/internal/admin/live_owner_test.go @@ -219,7 +219,8 @@ func newEditorHarness(t *testing.T) (*editorHarness, *gamenet.Session) { if err := g.AccountStore.SaveCharacter(p); err != nil { t.Fatal(err) } - sess := &gamenet.Session{Conn: discardConn{}, Account: acc, Player: p, State: gamenet.StateGame} + sess := &gamenet.Session{Conn: discardConn{}, Account: acc, Player: p} + sess.SetState(gamenet.StateGame) hub.Add(sess) hub.EnterRoom(sess, 1) g.Ticks.Start(2) 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) + } +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 2c8681a..12d090d 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -720,6 +720,13 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { if s.mobStore != nil { s.mobStore.RemoveMobsInRoom(id) } + // The room no longer exists, so its cached casino games + // (tables/machines) must be evicted like the direct-delete + // API does: outstanding wagers are refunded through the + // wired game callback before the cache entries drop. + if s.evictRoom != nil { + s.evictRoom(id) + } } } } @@ -728,6 +735,17 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { for _, p := range changeFiles(change) { s.invalidateEntityCacheForPath(p) } + + // Course definitions changed on disk (create/update/delete via undo or + // redo): the runtime course index must reload, exactly like the course + // API handlers do, or gameplay keeps traversing the stale course. + for _, p := range changeFiles(change) { + if rel, err := filepath.Rel(filepath.Join(s.dataDir, "courses"), p); err == nil && + !strings.HasPrefix(rel, "..") && rel != "." { + s.reloadCoursesIfWired() + break + } + } } func (s *AdminServer) invalidateEntityCaches(entityType string) { |
