summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-17 22:11:44 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-17 22:11:44 +0200
commit71a95f4ca6460a7c2bc345e8b32dbef03d6dd985 (patch)
tree48ba0ccdc1941343bdb2632ec5724ce38fd9ecfb
parente3021c88a0ce35b4c069e148a5d82bb1acbd3c91 (diff)
Refuse destroying nodes that still have children
NestedTree's before_destroy silently delete_all'd the whole subtree, bypassing every per-node cleanup. Nodes are never destroyed recursively; descendants must be removed individually.
-rw-r--r--app/models/concerns/nested_tree.rb8
-rw-r--r--app/models/node.rb16
-rw-r--r--test/models/node_test.rb37
3 files changed, 50 insertions, 11 deletions
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb
index 9a2eb0e..befcdb3 100644
--- a/app/models/concerns/nested_tree.rb
+++ b/app/models/concerns/nested_tree.rb
@@ -9,8 +9,6 @@ module NestedTree
9 included do 9 included do
10 belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children 10 belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children
11 has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent 11 has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent
12
13 before_destroy :delete_descendants
14 end 12 end
15 13
16 class_methods do 14 class_methods do
@@ -77,10 +75,4 @@ module NestedTree
77 def move_to_child_of(new_parent) 75 def move_to_child_of(new_parent)
78 update!(parent_id: new_parent.id) 76 update!(parent_id: new_parent.id)
79 end 77 end
80
81 private
82
83 def delete_descendants
84 self.class.where(id: descendants.pluck(:id)).delete_all
85 end
86end 78end
diff --git a/app/models/node.rb b/app/models/node.rb
index 70ed4da..6ce6591 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -12,9 +12,10 @@ class Node < ApplicationRecord
12 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true 12 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true
13 13
14 # Callbacks 14 # Callbacks
15 after_create :initialize_empty_page 15 after_create :initialize_empty_page
16 before_save :check_for_changed_slug 16 before_save :check_for_changed_slug
17 after_save :update_unique_names_of_children 17 after_save :update_unique_names_of_children
18 before_destroy :refuse_destroy_with_children
18 19
19 # Validations 20 # Validations
20 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } 21 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? }
@@ -366,6 +367,15 @@ class Node < ApplicationRecord
366 ) 367 )
367 end 368 end
368 369
370 # Nodes are never destroyed recursively
371 # Descendants must be removed or reparented individually first.
372 # The Trash feature will be the ordinary path to deletion.
373 def refuse_destroy_with_children
374 return unless children.exists?
375 errors.add(:base, "Cannot destroy a node that still has children")
376 throw :abort
377 end
378
369 def self.recently_changed 379 def self.recently_changed
370 includes(:head).where( 380 includes(:head).where(
371 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", 381 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index 4401651..9c4834e 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -770,4 +770,41 @@ class NodeTest < ActiveSupport::TestCase
770 node.default_template_name = "standard_template" 770 node.default_template_name = "standard_template"
771 assert node.valid? 771 assert node.valid?
772 end 772 end
773
774 test "destroying a node with children is refused" do
775 parent = Node.root.children.create!(:slug => "destroy_guard_parent")
776 parent.children.create!(:slug => "destroy_guard_child")
777
778 assert_no_difference "Node.count" do
779 assert_not parent.destroy
780 end
781 assert parent.errors[:base].any?
782 end
783
784 test "destroying a childless node leaves no orphaned pages or asset links" do
785 node = create_node_with_published_page
786 page_ids = node.pages.pluck(:id)
787 asset = Asset.create!(:name => "destroy cascade probe",
788 :upload_file_name => "test_image.png",
789 :upload_content_type => "image/png",
790 :upload_file_size => 49854,
791 :upload_updated_at => Time.current)
792 node.head.related_assets.create!(:asset_id => asset.id, :position => 1)
793
794 node.destroy
795
796 assert_equal 0, Page.where(:id => page_ids).count
797 assert_equal 0, RelatedAsset.where(:page_id => page_ids).count
798 end
799
800 test "destroying a node also removes its autosave page" do
801 node = create_node_with_published_page
802 node.lock_for_editing!(@user1)
803 node.autosave!({:title => "in flight"}, @user1)
804 autosave_id = node.autosave_id
805
806 node.destroy
807
808 assert_equal 0, Page.where(:id => autosave_id).count
809 end
773end 810end