diff options
Diffstat (limited to 'test')
| -rw-r--r-- | test/controllers/content_controller_test.rb | 13 | ||||
| -rw-r--r-- | test/controllers/csp_reports_controller_test.rb | 8 | ||||
| -rw-r--r-- | test/controllers/nodes_controller_test.rb | 105 | ||||
| -rw-r--r-- | test/fixtures/nodes.yml | 10 | ||||
| -rw-r--r-- | test/integration/csp_header_test.rb | 12 | ||||
| -rw-r--r-- | test/models/concerns/rrule_humanizer_test.rb | 7 | ||||
| -rw-r--r-- | test/models/helpers/node_actions_helper_test.rb | 39 | ||||
| -rw-r--r-- | test/models/node_test.rb | 101 | ||||
| -rw-r--r-- | test/models/node_trash_test.rb | 168 | ||||
| -rw-r--r-- | test/models/page_test.rb | 35 | ||||
| -rw-r--r-- | test/models/user_test.rb | 72 |
11 files changed, 553 insertions, 17 deletions
diff --git a/test/controllers/content_controller_test.rb b/test/controllers/content_controller_test.rb index e8bc55f..9731d08 100644 --- a/test/controllers/content_controller_test.rb +++ b/test/controllers/content_controller_test.rb | |||
| @@ -73,8 +73,7 @@ class ContentControllerTest < ActionController::TestCase | |||
| 73 | def test_nonexistant_custom_template_defaults_to_standard_template | 73 | def test_nonexistant_custom_template_defaults_to_standard_template |
| 74 | new_node = create_node_under_root "fnord" | 74 | new_node = create_node_under_root "fnord" |
| 75 | draft = find_or_create_draft(new_node, @user1) | 75 | draft = find_or_create_draft(new_node, @user1) |
| 76 | draft.template_name = "huchibu" | 76 | draft.update_column(:template_name, "huchibu") |
| 77 | draft.save | ||
| 78 | new_node.publish_draft! | 77 | new_node.publish_draft! |
| 79 | 78 | ||
| 80 | get :render_page, params: { :locale => 'de', :page_path => ["fnord"] } | 79 | get :render_page, params: { :locale => 'de', :page_path => ["fnord"] } |
| @@ -105,6 +104,16 @@ class ContentControllerTest < ActionController::TestCase | |||
| 105 | assert_response :success | 104 | assert_response :success |
| 106 | File.write("/tmp/no_fill_response.html", @response.body) | 105 | File.write("/tmp/no_fill_response.html", @response.body) |
| 107 | end | 106 | end |
| 107 | |||
| 108 | test "render_gallery renders for a published page" do | ||
| 109 | node = Node.root.children.create!(:slug => "gallery_render_test") | ||
| 110 | Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => "Galerie") } | ||
| 111 | node.publish_draft! | ||
| 112 | |||
| 113 | get :render_gallery, params: { :page_path => "gallery_render_test", :locale => "de" } | ||
| 114 | |||
| 115 | assert_response :success | ||
| 116 | end | ||
| 108 | 117 | ||
| 109 | protected | 118 | protected |
| 110 | 119 | ||
diff --git a/test/controllers/csp_reports_controller_test.rb b/test/controllers/csp_reports_controller_test.rb new file mode 100644 index 0000000..7dd8c9e --- /dev/null +++ b/test/controllers/csp_reports_controller_test.rb | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class CspReportsControllerTest < ActionController::TestCase | ||
| 4 | test "accepts anonymous reports without CSRF" do | ||
| 5 | post :create, body: '{"csp-report":{"violated-directive":"script-src"}}' | ||
| 6 | assert_response :no_content | ||
| 7 | end | ||
| 8 | end | ||
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 5b66bd3..ddc4565 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb | |||
| @@ -140,7 +140,7 @@ class NodesControllerTest < ActionController::TestCase | |||
| 140 | :page => { | 140 | :page => { |
| 141 | :title => "Hello", | 141 | :title => "Hello", |
| 142 | :body => "There", | 142 | :body => "There", |
| 143 | :template_name => "Foobar" | 143 | :template_name => "title_only" |
| 144 | } | 144 | } |
| 145 | } | 145 | } |
| 146 | 146 | ||
| @@ -148,7 +148,17 @@ class NodesControllerTest < ActionController::TestCase | |||
| 148 | test_node.reload | 148 | test_node.reload |
| 149 | assert_equal "Hello", test_node.head.title | 149 | assert_equal "Hello", test_node.head.title |
| 150 | assert_equal "There", test_node.head.body | 150 | assert_equal "There", test_node.head.body |
| 151 | assert_equal "Foobar", test_node.head.template_name | 151 | assert_equal "title_only", test_node.head.template_name |
| 152 | end | ||
| 153 | |||
| 154 | def test_update_rejects_a_template_name_not_on_disk | ||
| 155 | test_node = Node.root.children.create! :slug => "test_node" | ||
| 156 | login_as :quentin | ||
| 157 | put :update, params: { :id => test_node.id, | ||
| 158 | :page => { :title => "Hello", :template_name => "Foobar" } } | ||
| 159 | |||
| 160 | test_node.reload | ||
| 161 | assert_not_equal "Foobar", test_node.draft&.template_name | ||
| 152 | end | 162 | end |
| 153 | 163 | ||
| 154 | test "publish draft with staged_slug unqueal slug" do | 164 | test "publish draft with staged_slug unqueal slug" do |
| @@ -417,11 +427,11 @@ class NodesControllerTest < ActionController::TestCase | |||
| 417 | test "show never renders a destroy link for events" do | 427 | test "show never renders a destroy link for events" do |
| 418 | login_as :quentin | 428 | login_as :quentin |
| 419 | node = create_node_with_published_page | 429 | node = create_node_with_published_page |
| 420 | Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) | 430 | event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) |
| 421 | 431 | ||
| 422 | get :show, params: { id: node.id } | 432 | get :show, params: { id: node.id } |
| 423 | assert_response :success | 433 | assert_response :success |
| 424 | assert_select "form.button_to.destructive", count: 0 | 434 | assert_select "form[action=?]", event_path(event), count: 0 |
| 425 | end | 435 | end |
| 426 | 436 | ||
| 427 | test "reverting from nodes#show returns to the show page, not the editor, even if a draft remains" do | 437 | test "reverting from nodes#show returns to the show page, not the editor, even if a draft remains" do |
| @@ -469,7 +479,8 @@ class NodesControllerTest < ActionController::TestCase | |||
| 469 | login_as :quentin | 479 | login_as :quentin |
| 470 | get :show, params: { :id => node.id } | 480 | get :show, params: { :id => node.id } |
| 471 | assert_response :success | 481 | assert_response :success |
| 472 | assert_select "form.destructive", :count => 0 | 482 | assert_select "form[action=?]", revert_node_path(node), count: 0 |
| 483 | assert_select "form[action=?]", trash_node_path(node), count: 1 | ||
| 473 | end | 484 | end |
| 474 | 485 | ||
| 475 | test "drafts includes a never-published node with only a draft" do | 486 | test "drafts includes a never-published node with only a draft" do |
| @@ -662,4 +673,88 @@ class NodesControllerTest < ActionController::TestCase | |||
| 662 | assert_equal "Brand New", action.metadata["title"] | 673 | assert_equal "Brand New", action.metadata["title"] |
| 663 | assert_equal Node.last.unique_name, action.metadata["path"] | 674 | assert_equal Node.last.unique_name, action.metadata["path"] |
| 664 | end | 675 | end |
| 676 | |||
| 677 | test "trash moves the node and redirects to the Trash" do | ||
| 678 | login_as :quentin | ||
| 679 | node = Node.root.children.create!(:slug => "trash_me") | ||
| 680 | |||
| 681 | put :trash, params: { :id => node.id } | ||
| 682 | |||
| 683 | assert_redirected_to trashed_nodes_path | ||
| 684 | assert node.reload.in_trash? | ||
| 685 | end | ||
| 686 | |||
| 687 | test "trashing the Trash node itself is refused" do | ||
| 688 | login_as :quentin | ||
| 689 | |||
| 690 | put :trash, params: { :id => Node.trash.id } | ||
| 691 | |||
| 692 | assert_redirected_to node_path(Node.trash) | ||
| 693 | assert flash[:error].present? | ||
| 694 | end | ||
| 695 | |||
| 696 | test "restore_from_trash reparents to the given parent" do | ||
| 697 | login_as :quentin | ||
| 698 | node = Node.root.children.create!(:slug => "restore_me") | ||
| 699 | node.trash!(users(:quentin)) | ||
| 700 | target = Node.root.children.create!(:slug => "restore_home") | ||
| 701 | |||
| 702 | put :restore_from_trash, params: { :id => node.id, :parent_id => target.id } | ||
| 703 | |||
| 704 | assert_redirected_to node_path(node) | ||
| 705 | assert_equal target, node.reload.parent | ||
| 706 | end | ||
| 707 | |||
| 708 | test "destroy refuses a node outside the Trash" do | ||
| 709 | login_as :quentin | ||
| 710 | node = Node.root.children.create!(:slug => "not_deletable_here") | ||
| 711 | |||
| 712 | delete :destroy, params: { :id => node.id } | ||
| 713 | |||
| 714 | assert Node.exists?(node.id) | ||
| 715 | assert flash[:error].present? | ||
| 716 | end | ||
| 717 | |||
| 718 | test "destroy deletes a trashed node and redirects to the Trash" do | ||
| 719 | login_as :quentin | ||
| 720 | node = Node.root.children.create!(:slug => "deletable") | ||
| 721 | node.trash!(users(:quentin)) | ||
| 722 | |||
| 723 | delete :destroy, params: { :id => node.id } | ||
| 724 | |||
| 725 | assert_not Node.exists?(node.id) | ||
| 726 | assert_redirected_to trashed_nodes_path | ||
| 727 | end | ||
| 728 | |||
| 729 | test "trash lists trashed subtree roots" do | ||
| 730 | login_as :quentin | ||
| 731 | node = Node.root.children.create!(:slug => "listed_in_trash") | ||
| 732 | node.trash!(users(:quentin)) | ||
| 733 | |||
| 734 | get :trashed | ||
| 735 | assert_response :success | ||
| 736 | assert_select "a[href=?]", node_path(node) | ||
| 737 | end | ||
| 738 | |||
| 739 | test "trashed rows carry provenance and a delete for childless roots" do | ||
| 740 | login_as :quentin | ||
| 741 | node = Node.root.children.create!(:slug => "provenance_test") | ||
| 742 | node.trash!(users(:quentin)) | ||
| 743 | |||
| 744 | get :trashed | ||
| 745 | assert_select "td", /quentin/ | ||
| 746 | assert_select "form[action=?]", node_path(node), count: 1 | ||
| 747 | end | ||
| 748 | |||
| 749 | test "show annotates history rows with their lifecycle" do | ||
| 750 | login_as :quentin | ||
| 751 | node = Node.root.children.create!(:slug => "history_annotation_test") | ||
| 752 | Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => "Annotated") } | ||
| 753 | node.publish_draft!(users(:quentin)) | ||
| 754 | |||
| 755 | get :show, params: { :id => node.id } | ||
| 756 | |||
| 757 | assert_response :success | ||
| 758 | assert_select "span.revision_lifecycle", /quentin/ | ||
| 759 | end | ||
| 665 | end | 760 | end |
diff --git a/test/fixtures/nodes.yml b/test/fixtures/nodes.yml index 9ca325e..e94f10f 100644 --- a/test/fixtures/nodes.yml +++ b/test/fixtures/nodes.yml | |||
| @@ -2,8 +2,6 @@ | |||
| 2 | 2 | ||
| 3 | root: | 3 | root: |
| 4 | id: 1 | 4 | id: 1 |
| 5 | lft: 1 | ||
| 6 | rgt: 10 | ||
| 7 | parent_id: | 5 | parent_id: |
| 8 | slug: | 6 | slug: |
| 9 | unique_name: | 7 | unique_name: |
| @@ -11,8 +9,6 @@ root: | |||
| 11 | 9 | ||
| 12 | first_child: | 10 | first_child: |
| 13 | id: 2 | 11 | id: 2 |
| 14 | lft: 2 | ||
| 15 | rgt: 3 | ||
| 16 | parent_id: 1 | 12 | parent_id: 1 |
| 17 | draft_id: 100 | 13 | draft_id: 100 |
| 18 | slug: first_child | 14 | slug: first_child |
| @@ -20,8 +16,6 @@ first_child: | |||
| 20 | 16 | ||
| 21 | second_child: | 17 | second_child: |
| 22 | id: 3 | 18 | id: 3 |
| 23 | lft: 4 | ||
| 24 | rgt: 5 | ||
| 25 | parent_id: 1 | 19 | parent_id: 1 |
| 26 | draft_id: 101 | 20 | draft_id: 101 |
| 27 | slug: second_child | 21 | slug: second_child |
| @@ -29,8 +23,6 @@ second_child: | |||
| 29 | 23 | ||
| 30 | third_child: | 24 | third_child: |
| 31 | id: 4 | 25 | id: 4 |
| 32 | lft: 6 | ||
| 33 | rgt: 7 | ||
| 34 | parent_id: 1 | 26 | parent_id: 1 |
| 35 | draft_id: 102 | 27 | draft_id: 102 |
| 36 | slug: third_child | 28 | slug: third_child |
| @@ -38,8 +30,6 @@ third_child: | |||
| 38 | 30 | ||
| 39 | fourth_child: | 31 | fourth_child: |
| 40 | id: 5 | 32 | id: 5 |
| 41 | lft: 8 | ||
| 42 | rgt: 9 | ||
| 43 | parent_id: 1 | 33 | parent_id: 1 |
| 44 | slug: fourth_child | 34 | slug: fourth_child |
| 45 | unique_name: fourth_child | 35 | unique_name: fourth_child |
diff --git a/test/integration/csp_header_test.rb b/test/integration/csp_header_test.rb new file mode 100644 index 0000000..73707ed --- /dev/null +++ b/test/integration/csp_header_test.rb | |||
| @@ -0,0 +1,12 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class CspHeaderTest < ActionDispatch::IntegrationTest | ||
| 4 | test "public responses carry the report-only CSP header with a nonce" do | ||
| 5 | get "/" | ||
| 6 | |||
| 7 | header = response.headers["Content-Security-Policy-Report-Only"] | ||
| 8 | assert header.present? | ||
| 9 | assert_includes header, "script-src" | ||
| 10 | assert_includes header, "nonce-" | ||
| 11 | end | ||
| 12 | end | ||
diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb index 54c4c22..d747171 100644 --- a/test/models/concerns/rrule_humanizer_test.rb +++ b/test/models/concerns/rrule_humanizer_test.rb | |||
| @@ -114,4 +114,11 @@ class RruleHumanizerTest < ActiveSupport::TestCase | |||
| 114 | test "wday_abbr falls back to :de for an unrecognized locale" do | 114 | test "wday_abbr falls back to :de for an unrecognized locale" do |
| 115 | assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr) | 115 | assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr) |
| 116 | end | 116 | end |
| 117 | |||
| 118 | test "monthly two selected weeks" do | ||
| 119 | assert_equal "Jeden ersten und dritten Dienstag im Monat", | ||
| 120 | humanize("FREQ=MONTHLY;BYDAY=1TU,3TU") | ||
| 121 | assert_equal "Every first and third Tuesday of the month", | ||
| 122 | humanize("FREQ=MONTHLY;BYDAY=1TU,3TU", :en) | ||
| 123 | end | ||
| 117 | end | 124 | end |
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb index 5244c19..1b72ec9 100644 --- a/test/models/helpers/node_actions_helper_test.rb +++ b/test/models/helpers/node_actions_helper_test.rb | |||
| @@ -10,6 +10,10 @@ class NodeActionsHelperTest < ActionView::TestCase | |||
| 10 | { :locale => nil } | 10 | { :locale => nil } |
| 11 | end | 11 | end |
| 12 | 12 | ||
| 13 | def icon(_name, **) | ||
| 14 | '<svg class="stub-icon"></svg>'.html_safe | ||
| 15 | end | ||
| 16 | |||
| 13 | def teardown | 17 | def teardown |
| 14 | I18n.locale = @original_locale | 18 | I18n.locale = @original_locale |
| 15 | end | 19 | end |
| @@ -104,4 +108,39 @@ class NodeActionsHelperTest < ActionView::TestCase | |||
| 104 | assert_includes action_summary(e), "a/b" | 108 | assert_includes action_summary(e), "a/b" |
| 105 | assert_not action_details?(e) | 109 | assert_not action_details?(e) |
| 106 | end | 110 | end |
| 111 | |||
| 112 | test "trash, restore, and destroy render their paths" do | ||
| 113 | trash = entry("trash", { "path" => { "from" => "club/old", "to" => "trash/old" } }) | ||
| 114 | restore = entry("restore_from_trash", { "path" => { "from" => "trash/old", "to" => "club/new" } }) | ||
| 115 | doomed = entry("destroy", { "path" => "trash/old" }) | ||
| 116 | |||
| 117 | assert_includes action_summary(trash), "club/old" | ||
| 118 | assert_includes action_summary(restore), "club/new" | ||
| 119 | assert_includes action_summary(doomed), "trash/old" | ||
| 120 | end | ||
| 121 | |||
| 122 | test "revision lifecycle badges cover create, publish, rollback, and inference" do | ||
| 123 | created = entry("create", { "title" => "x", "path" => "a/b" }) | ||
| 124 | published = entry("publish", { "via" => "draft", "title" => { "from" => nil, "to" => "x" } }) | ||
| 125 | restored = entry("publish", { "via" => "revision", "title" => { "from" => "y", "to" => "x" } }) | ||
| 126 | restored.update!(:inferred_from => "from_page_revision") | ||
| 127 | |||
| 128 | out = revision_lifecycle_badges([created, published, restored]) | ||
| 129 | |||
| 130 | assert_includes out, "created" | ||
| 131 | assert_includes out, "published" | ||
| 132 | assert_includes out, "restored" | ||
| 133 | assert_includes out, "quentin" | ||
| 134 | assert_includes out, "node_action_inferred" | ||
| 135 | assert_includes out, "<span" | ||
| 136 | assert_not_includes out, "<span" | ||
| 137 | |||
| 138 | assert_equal "", revision_lifecycle_badges(nil) | ||
| 139 | end | ||
| 140 | |||
| 141 | test "verb icons map known verbs, distinguish rollbacks, and fall back" do | ||
| 142 | assert_includes verb_icon(entry("trash", { "path" => { "from" => "a", "to" => "t/a" } })), "node_action_icon--trash" | ||
| 143 | assert_includes verb_icon(entry("publish", { "via" => "revision" })), "node_action_icon--history" | ||
| 144 | assert_includes verb_icon(entry("frobnicate")), "node_action_icon--circle-dashed" | ||
| 145 | end | ||
| 107 | end | 146 | end |
diff --git a/test/models/node_test.rb b/test/models/node_test.rb index c8e49e5..ab66f81 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb | |||
| @@ -761,4 +761,105 @@ class NodeTest < ActiveSupport::TestCase | |||
| 761 | assert_equal "Second", action.metadata.dig("title", "from") | 761 | assert_equal "Second", action.metadata.dig("title", "from") |
| 762 | assert_equal "First", action.metadata.dig("title", "to") | 762 | assert_equal "First", action.metadata.dig("title", "to") |
| 763 | end | 763 | end |
| 764 | |||
| 765 | test "default_template_name rejects values not present in the template directory" do | ||
| 766 | node = Node.root.children.build(:slug => "template_guard_test", | ||
| 767 | :default_template_name => "../../layouts/admin") | ||
| 768 | assert_not node.valid? | ||
| 769 | |||
| 770 | node.default_template_name = "standard_template" | ||
| 771 | assert node.valid? | ||
| 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 | ||
| 810 | |||
| 811 | test "Node.trash lazily creates the container exactly once" do | ||
| 812 | assert_difference "Node.count", 1 do | ||
| 813 | Node.trash | ||
| 814 | end | ||
| 815 | assert_no_difference "Node.count" do | ||
| 816 | assert_equal Node.trash, Node.trash | ||
| 817 | end | ||
| 818 | assert Node.trash.trash_node? | ||
| 819 | assert_not Node.trash.in_trash? | ||
| 820 | assert_equal "Trash", Globalize.with_locale(I18n.default_locale) { Node.trash.draft.title } | ||
| 821 | end | ||
| 822 | |||
| 823 | test "in_trash? walks the whole parent chain" do | ||
| 824 | child = Node.trash.children.create!(:slug => "trashed_thing") | ||
| 825 | grandchild = child.children.create!(:slug => "trashed_deeper") | ||
| 826 | |||
| 827 | assert child.in_trash? | ||
| 828 | assert grandchild.in_trash? | ||
| 829 | assert_not Node.root.children.create!(:slug => "living_thing").in_trash? | ||
| 830 | end | ||
| 831 | |||
| 832 | test "the reserved slug is refused for other root children, live and staged" do | ||
| 833 | Node.trash | ||
| 834 | |||
| 835 | assert_not Node.root.children.build(:slug => CccConventions::TRASH_SLUG).valid? | ||
| 836 | assert_not Node.root.children.build(:slug => "fine", :staged_slug => CccConventions::TRASH_SLUG).valid? | ||
| 837 | assert Node.trash.children.create!(:slug => "sub").children.build(:slug => CccConventions::TRASH_SLUG).valid? | ||
| 838 | end | ||
| 839 | |||
| 840 | test "the Trash node refuses rename, move, and destroy" do | ||
| 841 | trash = Node.trash | ||
| 842 | other = Node.root.children.create!(:slug => "not_the_trash") | ||
| 843 | |||
| 844 | trash.slug = "recycling" | ||
| 845 | assert_not trash.valid? | ||
| 846 | |||
| 847 | trash.reload.parent_id = other.id | ||
| 848 | assert_not trash.valid? | ||
| 849 | |||
| 850 | assert_no_difference "Node.count" do | ||
| 851 | assert_not trash.reload.destroy | ||
| 852 | end | ||
| 853 | end | ||
| 854 | |||
| 855 | test "a head cannot exist inside the Trash, and publishing there is refused" do | ||
| 856 | node = Node.trash.children.create!(:slug => "no_publish_here") | ||
| 857 | page = node.pages.create! | ||
| 858 | |||
| 859 | node.head = page | ||
| 860 | assert_not node.valid? | ||
| 861 | |||
| 862 | node.reload | ||
| 863 | assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft! } | ||
| 864 | end | ||
| 764 | end | 865 | end |
diff --git a/test/models/node_trash_test.rb b/test/models/node_trash_test.rb new file mode 100644 index 0000000..52069d7 --- /dev/null +++ b/test/models/node_trash_test.rb | |||
| @@ -0,0 +1,168 @@ | |||
| 1 | require "test_helper" | ||
| 2 | |||
| 3 | class NodeTrashTest < ActiveSupport::TestCase | ||
| 4 | |||
| 5 | def setup | ||
| 6 | @user1 = User.create!(:login => "trasher", :email => "t@example.com", | ||
| 7 | :password => "foobar", :password_confirmation => "foobar") | ||
| 8 | end | ||
| 9 | |||
| 10 | test "trash! demotes the head into the draft slot when no draft exists" do | ||
| 11 | node = create_node_with_published_page | ||
| 12 | former_head = node.head | ||
| 13 | |||
| 14 | node.trash!(@user1) | ||
| 15 | node.reload | ||
| 16 | |||
| 17 | assert_nil node.head_id | ||
| 18 | assert_equal former_head, node.draft | ||
| 19 | assert node.in_trash? | ||
| 20 | end | ||
| 21 | |||
| 22 | test "trash! keeps an existing draft; the former head stays a plain revision" do | ||
| 23 | node = create_node_with_published_page | ||
| 24 | draft = find_or_create_draft(node, @user1) | ||
| 25 | former_head = node.head | ||
| 26 | |||
| 27 | node.trash!(@user1) | ||
| 28 | node.reload | ||
| 29 | |||
| 30 | assert_nil node.head_id | ||
| 31 | assert_equal draft, node.draft | ||
| 32 | assert_includes node.pages, former_head | ||
| 33 | end | ||
| 34 | |||
| 35 | test "trash! demotes descendant heads and counts them in the log entry" do | ||
| 36 | parent = create_node_with_published_page | ||
| 37 | child = parent.children.create!(:slug => "trashed_child") | ||
| 38 | child.draft.update!(:title => "Child") | ||
| 39 | child.publish_draft!(@user1) | ||
| 40 | |||
| 41 | parent.trash!(@user1) | ||
| 42 | |||
| 43 | action = NodeAction.where(:action => "trash").last | ||
| 44 | assert_equal parent, action.node | ||
| 45 | assert_equal 2, action.metadata["demoted_heads"] | ||
| 46 | assert action.metadata["was_published"] | ||
| 47 | assert_nil child.reload.head_id | ||
| 48 | assert child.in_trash? | ||
| 49 | end | ||
| 50 | |||
| 51 | test "trash! logs one entry with the path pair and updates unique names" do | ||
| 52 | node = create_node_with_published_page | ||
| 53 | path_before = node.unique_name | ||
| 54 | |||
| 55 | assert_difference "NodeAction.count", 1 do | ||
| 56 | node.trash!(@user1) | ||
| 57 | end | ||
| 58 | |||
| 59 | action = NodeAction.last | ||
| 60 | assert_equal path_before, action.metadata.dig("path", "from") | ||
| 61 | assert_equal node.reload.unique_name, action.metadata.dig("path", "to") | ||
| 62 | assert action.metadata.dig("path", "to").start_with?(CccConventions::TRASH_SLUG) | ||
| 63 | end | ||
| 64 | |||
| 65 | test "trash! on an already trashed node is a logged-nothing no-op" do | ||
| 66 | node = create_node_with_published_page | ||
| 67 | node.trash!(@user1) | ||
| 68 | |||
| 69 | assert_no_difference "NodeAction.count" do | ||
| 70 | assert_nil node.reload.trash!(@user1) | ||
| 71 | end | ||
| 72 | end | ||
| 73 | |||
| 74 | test "restore_from_trash! reparents as drafts without republishing" do | ||
| 75 | node = create_node_with_published_page | ||
| 76 | node.trash!(@user1) | ||
| 77 | target = Node.root.children.create!(:slug => "restore_target") | ||
| 78 | |||
| 79 | node.reload.restore_from_trash!(target, @user1) | ||
| 80 | node.reload | ||
| 81 | |||
| 82 | assert_equal target, node.parent | ||
| 83 | assert_not node.in_trash? | ||
| 84 | assert_nil node.head_id | ||
| 85 | assert_not_nil node.draft_id | ||
| 86 | assert_equal "restore_from_trash", NodeAction.last.action | ||
| 87 | end | ||
| 88 | |||
| 89 | test "restore_from_trash! refuses targets inside the Trash and the Trash itself" do | ||
| 90 | node = create_node_with_published_page | ||
| 91 | node.trash!(@user1) | ||
| 92 | other_trashed = Node.trash.children.create!(:slug => "also_trashed") | ||
| 93 | |||
| 94 | assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(Node.trash, @user1) } | ||
| 95 | assert_raises(ActiveRecord::RecordInvalid) { node.reload.restore_from_trash!(other_trashed, @user1) } | ||
| 96 | end | ||
| 97 | |||
| 98 | test "destroy_from_trash! refuses nodes outside the Trash" do | ||
| 99 | node = create_node_with_published_page | ||
| 100 | |||
| 101 | assert_raises(ActiveRecord::RecordInvalid) { node.destroy_from_trash!(@user1) } | ||
| 102 | assert Node.exists?(node.id) | ||
| 103 | end | ||
| 104 | |||
| 105 | test "destroy_from_trash! removes the whole subtree bottom-up with one entry" do | ||
| 106 | parent = create_node_with_published_page | ||
| 107 | child = parent.children.create!(:slug => "doomed_child") | ||
| 108 | grandchild = child.children.create!(:slug => "doomed_grandchild") | ||
| 109 | page_ids = [parent, child, grandchild].flat_map { |n| n.pages.pluck(:id) } | ||
| 110 | parent.trash!(@user1) | ||
| 111 | |||
| 112 | assert_difference "NodeAction.count", 1 do | ||
| 113 | parent.reload.destroy_from_trash!(@user1) | ||
| 114 | end | ||
| 115 | |||
| 116 | assert_not Node.exists?(parent.id) | ||
| 117 | assert_not Node.exists?(child.id) | ||
| 118 | assert_not Node.exists?(grandchild.id) | ||
| 119 | assert_equal 0, Page.where(:id => page_ids).count | ||
| 120 | |||
| 121 | action = NodeAction.where(:action => "destroy").last | ||
| 122 | assert_equal 2, action.metadata["destroyed_descendants"] | ||
| 123 | assert_nil action.node_id | ||
| 124 | end | ||
| 125 | |||
| 126 | test "destroy_from_trash! logs an entry that survives the node" do | ||
| 127 | node = create_node_with_published_page | ||
| 128 | Globalize.with_locale(I18n.default_locale) { node.head.update!(:title => "Doomed") } | ||
| 129 | node.trash!(@user1) | ||
| 130 | final_path = node.reload.unique_name | ||
| 131 | |||
| 132 | node.destroy_from_trash!(@user1) | ||
| 133 | |||
| 134 | action = NodeAction.where(:action => "destroy").last | ||
| 135 | assert_nil action.node_id | ||
| 136 | assert_equal final_path, action.metadata["path"] | ||
| 137 | assert_equal "Doomed", action.subject_name | ||
| 138 | assert_equal @user1, action.user | ||
| 139 | end | ||
| 140 | |||
| 141 | test "suggested_restore_parent finds the old parent while it lives" do | ||
| 142 | parent = Node.root.children.create!(:slug => "old_home") | ||
| 143 | node = parent.children.create!(:slug => "comes_back") | ||
| 144 | node.trash!(@user1) | ||
| 145 | |||
| 146 | assert_equal parent, node.reload.suggested_restore_parent | ||
| 147 | |||
| 148 | parent.trash!(@user1) | ||
| 149 | assert_nil node.reload.suggested_restore_parent | ||
| 150 | end | ||
| 151 | |||
| 152 | test "suggested_restore_parent is root for trashed top-level nodes" do | ||
| 153 | node = Node.root.children.create!(:slug => "was_top_level") | ||
| 154 | node.trash!(@user1) | ||
| 155 | |||
| 156 | assert_equal Node.root, node.reload.suggested_restore_parent | ||
| 157 | end | ||
| 158 | |||
| 159 | test "trashed nodes and the Trash itself stay out of the drafts surfaces" do | ||
| 160 | Node.trash | ||
| 161 | node = create_node_with_published_page | ||
| 162 | node.trash!(@user1) | ||
| 163 | |||
| 164 | ids = Node.drafts_and_autosaves.pluck(:id) | ||
| 165 | assert_not_includes ids, Node.trash.id | ||
| 166 | assert_not_includes ids, node.id | ||
| 167 | end | ||
| 168 | end | ||
diff --git a/test/models/page_test.rb b/test/models/page_test.rb index edb7c37..1f924f9 100644 --- a/test/models/page_test.rb +++ b/test/models/page_test.rb | |||
| @@ -370,4 +370,39 @@ class PageTest < ActiveSupport::TestCase | |||
| 370 | assert en_entry[:changed] | 370 | assert en_entry[:changed] |
| 371 | refute en_entry[:exists_there] | 371 | refute en_entry[:exists_there] |
| 372 | end | 372 | end |
| 373 | |||
| 374 | test "aggregate ignores order_by values outside the allowlist" do | ||
| 375 | sql = Page.aggregate(:order_by => "pages.id; DROP TABLE pages--").to_sql | ||
| 376 | |||
| 377 | assert_not_includes sql, "DROP" | ||
| 378 | assert_includes sql, "pages.id ASC" | ||
| 379 | end | ||
| 380 | |||
| 381 | test "aggregate accepts allowlisted order columns, bare or prefixed" do | ||
| 382 | assert_includes Page.aggregate(:order_by => "published_at").to_sql, | ||
| 383 | "pages.published_at ASC" | ||
| 384 | assert_includes Page.aggregate(:order_by => "pages.published_at").to_sql, | ||
| 385 | "pages.published_at ASC" | ||
| 386 | end | ||
| 387 | |||
| 388 | test "template_name rejects values not present in the template directory" do | ||
| 389 | page = Page.create!(:title => "Template guard") | ||
| 390 | |||
| 391 | page.template_name = "../../partials/_article" | ||
| 392 | assert_not page.valid? | ||
| 393 | |||
| 394 | page.template_name = "standard_template" | ||
| 395 | assert page.valid? | ||
| 396 | |||
| 397 | page.template_name = "" | ||
| 398 | assert page.valid? | ||
| 399 | end | ||
| 400 | |||
| 401 | test "a stale legacy template_name does not block unrelated saves" do | ||
| 402 | page = Page.create!(:title => "Stale template") | ||
| 403 | page.update_column(:template_name, "long_deleted_template") | ||
| 404 | |||
| 405 | page.reload | ||
| 406 | assert page.update(:abstract => "still saveable") | ||
| 407 | end | ||
| 373 | end | 408 | end |
diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 6e4d2d7..feccce2 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb | |||
| @@ -54,6 +54,78 @@ class UserTest < ActiveSupport::TestCase | |||
| 54 | def test_should_authenticate_user | 54 | def test_should_authenticate_user |
| 55 | assert_equal users(:quentin), User.authenticate('quentin', 'monkey') | 55 | assert_equal users(:quentin), User.authenticate('quentin', 'monkey') |
| 56 | end | 56 | end |
| 57 | |||
| 58 | def test_should_not_authenticate_wrong_password | ||
| 59 | assert_nil User.authenticate("quentin", "wrong password") | ||
| 60 | end | ||
| 61 | |||
| 62 | def test_should_not_authenticate_unknown_user | ||
| 63 | assert_nil User.authenticate("nosuchuser", "monkey") | ||
| 64 | end | ||
| 65 | |||
| 66 | def test_user_with_crypted_password_is_migrated_on_login | ||
| 67 | user = users(:quentin) | ||
| 68 | |||
| 69 | assert_nil user.password_digest | ||
| 70 | |||
| 71 | assert User.authenticate("quentin", "monkey") | ||
| 72 | |||
| 73 | user.reload | ||
| 74 | |||
| 75 | assert_not_nil user.password_digest | ||
| 76 | assert_nil user.crypted_password | ||
| 77 | assert_nil user.salt | ||
| 78 | end | ||
| 79 | |||
| 80 | def test_new_user_uses_password_digest | ||
| 81 | user = create_user | ||
| 82 | |||
| 83 | assert_not_nil user.password_digest | ||
| 84 | assert_nil user.crypted_password | ||
| 85 | assert_nil user.salt | ||
| 86 | |||
| 87 | assert_equal user, User.authenticate("quire", "quire69") | ||
| 88 | end | ||
| 89 | |||
| 90 | def test_legacy_user_is_migrated_on_login | ||
| 91 | user = users(:quentin) | ||
| 92 | |||
| 93 | assert_nil user.password_digest | ||
| 94 | assert_not_nil user.crypted_password | ||
| 95 | assert_not_nil user.salt | ||
| 96 | |||
| 97 | assert_equal user, User.authenticate("quentin", "monkey") | ||
| 98 | |||
| 99 | user.reload | ||
| 100 | |||
| 101 | assert_not_nil user.password_digest | ||
| 102 | assert_nil user.crypted_password | ||
| 103 | assert_nil user.salt | ||
| 104 | end | ||
| 105 | |||
| 106 | def test_migrated_user_authenticates_using_password_digest | ||
| 107 | user = users(:quentin) | ||
| 108 | |||
| 109 | # Trigger automatic migration. | ||
| 110 | assert_equal user, User.authenticate("quentin", "monkey") | ||
| 111 | |||
| 112 | user.reload | ||
| 113 | |||
| 114 | assert_not_nil user.password_digest | ||
| 115 | assert_nil user.crypted_password | ||
| 116 | assert_nil user.salt | ||
| 117 | |||
| 118 | # Second login should now use password_digest. | ||
| 119 | assert_equal user, User.authenticate("quentin", "monkey") | ||
| 120 | end | ||
| 121 | |||
| 122 | def test_migrated_user_can_be_updated_without_password | ||
| 123 | user = users(:quentin) | ||
| 124 | assert_equal user, User.authenticate("quentin", "monkey") | ||
| 125 | user.reload | ||
| 126 | |||
| 127 | assert user.update(:email => "quentin@example.org") | ||
| 128 | end | ||
| 57 | 129 | ||
| 58 | protected | 130 | protected |
| 59 | def create_user(options = {}) | 131 | def create_user(options = {}) |
