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/node_actions_controller_test.rb | 51 | ||||
| -rw-r--r-- | test/controllers/nodes_controller_test.rb | 120 | ||||
| -rw-r--r-- | test/controllers/page_translations_controller_test.rb | 11 | ||||
| -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 | 25 | ||||
| -rw-r--r-- | test/models/helpers/node_actions_helper_test.rb | 146 | ||||
| -rw-r--r-- | test/models/node_action_test.rb | 128 | ||||
| -rw-r--r-- | test/models/node_test.rb | 164 | ||||
| -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 |
14 files changed, 936 insertions, 27 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/node_actions_controller_test.rb b/test/controllers/node_actions_controller_test.rb new file mode 100644 index 0000000..8bc68ec --- /dev/null +++ b/test/controllers/node_actions_controller_test.rb | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class NodeActionsControllerTest < ActionController::TestCase | ||
| 4 | |||
| 5 | def setup | ||
| 6 | login_as :quentin | ||
| 7 | end | ||
| 8 | |||
| 9 | def logged_node slug, title | ||
| 10 | node = Node.root.children.create!(:slug => slug) | ||
| 11 | Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => title) } | ||
| 12 | node | ||
| 13 | end | ||
| 14 | |||
| 15 | test "index renders" do | ||
| 16 | get :index | ||
| 17 | assert_response :success | ||
| 18 | end | ||
| 19 | |||
| 20 | test "index filters by node" do | ||
| 21 | node_a = logged_node("log_filter_a", "Alpha Subject") | ||
| 22 | node_b = logged_node("log_filter_b", "Beta Subject") | ||
| 23 | NodeAction.record!(:node => node_a, :action => "create", :user => users(:quentin)) | ||
| 24 | NodeAction.record!(:node => node_b, :action => "create", :user => users(:quentin)) | ||
| 25 | |||
| 26 | get :index, params: { :node_id => node_a.id } | ||
| 27 | |||
| 28 | assert_response :success | ||
| 29 | assert_includes @response.body, "Alpha Subject" | ||
| 30 | assert_not_includes @response.body, "Beta Subject" | ||
| 31 | end | ||
| 32 | |||
| 33 | test "index filters by user" do | ||
| 34 | node = logged_node("log_filter_user", "Gamma Subject") | ||
| 35 | NodeAction.record!(:node => node, :action => "create", :user => users(:quentin)) | ||
| 36 | |||
| 37 | get :index, params: { :user_id => users(:quentin).id } | ||
| 38 | assert_includes @response.body, "Gamma Subject" | ||
| 39 | |||
| 40 | other = User.where.not(:id => users(:quentin).id).first | ||
| 41 | get :index, params: { :user_id => other.id } | ||
| 42 | assert_not_includes @response.body, "Gamma Subject" | ||
| 43 | end | ||
| 44 | |||
| 45 | test "index requires login" do | ||
| 46 | session[:user_id] = nil | ||
| 47 | @controller.instance_variable_set(:@current_user, nil) | ||
| 48 | get :index | ||
| 49 | assert_response :redirect | ||
| 50 | end | ||
| 51 | end | ||
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 9da9514..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 |
| @@ -647,4 +658,103 @@ class NodesControllerTest < ActionController::TestCase | |||
| 647 | assert_select ".sitemap_node_actions", :text => /Create Child/ | 658 | assert_select ".sitemap_node_actions", :text => /Create Child/ |
| 648 | assert_select ".sitemap_node_actions", :text => /Revisions/, :count => 0 | 659 | assert_select ".sitemap_node_actions", :text => /Revisions/, :count => 0 |
| 649 | end | 660 | end |
| 661 | |||
| 662 | test "create logs a create NodeAction with path and title" do | ||
| 663 | login_as :quentin | ||
| 664 | |||
| 665 | assert_difference "NodeAction.count" do | ||
| 666 | post :create, params: { :kind => "generic", :title => "Brand New", :parent_id => Node.root.id } | ||
| 667 | end | ||
| 668 | |||
| 669 | action = NodeAction.last | ||
| 670 | assert_equal "create", action.action | ||
| 671 | assert_equal users(:quentin), action.user | ||
| 672 | assert_equal Node.last, action.node | ||
| 673 | assert_equal "Brand New", action.metadata["title"] | ||
| 674 | assert_equal Node.last.unique_name, action.metadata["path"] | ||
| 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 | ||
| 650 | end | 760 | end |
diff --git a/test/controllers/page_translations_controller_test.rb b/test/controllers/page_translations_controller_test.rb index 82f8bce..feaacd0 100644 --- a/test/controllers/page_translations_controller_test.rb +++ b/test/controllers/page_translations_controller_test.rb | |||
| @@ -83,18 +83,15 @@ class PageTranslationsControllerTest < ActionController::TestCase | |||
| 83 | assert_equal "in progress", Globalize.with_locale(:en) { node.autosave.title } | 83 | assert_equal "in progress", Globalize.with_locale(:en) { node.autosave.title } |
| 84 | end | 84 | end |
| 85 | 85 | ||
| 86 | test "destroy logs a translation_destroy NodeAction" do | 86 | test "destroy removes the translation, logs nothing, and redirects" do |
| 87 | login_as :quentin | 87 | login_as :quentin |
| 88 | node = Node.root.children.create!(:slug => "translation_destroy_log_test") | 88 | node = Node.root.children.create!(:slug => "translation_destroy_log_test") |
| 89 | Globalize.with_locale(:en) { node.draft.update!(:title => "English title") } | 89 | Globalize.with_locale(:en) { node.draft.update!(:title => "English title") } |
| 90 | 90 | ||
| 91 | delete :destroy, params: { :node_id => node.id, :translation_locale => "en" } | 91 | assert_no_difference "NodeAction.count" do |
| 92 | delete :destroy, params: { :node_id => node.id, :translation_locale => "en" } | ||
| 93 | end | ||
| 92 | 94 | ||
| 93 | action = NodeAction.last | ||
| 94 | assert_equal node, action.node | ||
| 95 | assert_equal "en", action.locale | ||
| 96 | assert_equal "translation_destroy", action.action | ||
| 97 | assert_equal users(:quentin), action.user | ||
| 98 | assert_redirected_to node_path(node) | 95 | assert_redirected_to node_path(node) |
| 99 | end | 96 | end |
| 100 | end | 97 | 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 279ff73..d747171 100644 --- a/test/models/concerns/rrule_humanizer_test.rb +++ b/test/models/concerns/rrule_humanizer_test.rb | |||
| @@ -47,6 +47,24 @@ class RruleHumanizerTest < ActiveSupport::TestCase | |||
| 47 | assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) | 47 | assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) |
| 48 | end | 48 | end |
| 49 | 49 | ||
| 50 | test "monthly selected weeks" do | ||
| 51 | assert_equal "Jeden ersten, zweiten und fünften Dienstag im Monat", | ||
| 52 | humanize("FREQ=MONTHLY;BYDAY=1TU,2TU,5TU") | ||
| 53 | assert_equal "Every first, second and fifth Tuesday of the month", | ||
| 54 | humanize("FREQ=MONTHLY;BYDAY=1TU,2TU,5TU", :en) | ||
| 55 | end | ||
| 56 | |||
| 57 | test "monthly fifth weekday" do | ||
| 58 | assert_equal "Jeden fünften Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=5TU") | ||
| 59 | assert_equal "Every fifth Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=5TU", :en) | ||
| 60 | end | ||
| 61 | |||
| 62 | test "monthly weekday excluding one ordinal" do | ||
| 63 | rrule = "FREQ=MONTHLY;BYDAY=1TU,3TU,4TU,5TU" | ||
| 64 | assert_equal "Jeden Dienstag im Monat, außer dem zweiten Dienstag", humanize(rrule) | ||
| 65 | assert_equal "Every Tuesday of the month, except the second Tuesday", humanize(rrule, :en) | ||
| 66 | end | ||
| 67 | |||
| 50 | test "monthly no byday" do | 68 | test "monthly no byday" do |
| 51 | assert_equal "Monatlich", humanize("FREQ=MONTHLY") | 69 | assert_equal "Monatlich", humanize("FREQ=MONTHLY") |
| 52 | assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) | 70 | assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) |
| @@ -96,4 +114,11 @@ class RruleHumanizerTest < ActiveSupport::TestCase | |||
| 96 | 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 |
| 97 | 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) |
| 98 | 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 | ||
| 99 | end | 124 | end |
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb new file mode 100644 index 0000000..1b72ec9 --- /dev/null +++ b/test/models/helpers/node_actions_helper_test.rb | |||
| @@ -0,0 +1,146 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class NodeActionsHelperTest < ActionView::TestCase | ||
| 4 | def setup | ||
| 5 | @original_locale = I18n.locale | ||
| 6 | I18n.locale = :en | ||
| 7 | end | ||
| 8 | |||
| 9 | def default_url_options | ||
| 10 | { :locale => nil } | ||
| 11 | end | ||
| 12 | |||
| 13 | def icon(_name, **) | ||
| 14 | '<svg class="stub-icon"></svg>'.html_safe | ||
| 15 | end | ||
| 16 | |||
| 17 | def teardown | ||
| 18 | I18n.locale = @original_locale | ||
| 19 | end | ||
| 20 | |||
| 21 | def entry action, metadata = {}, node: nil, user: nil, page: nil | ||
| 22 | NodeAction.create!(:node => node, :user => user, :page => page, | ||
| 23 | :action => action, :occurred_at => Time.now, | ||
| 24 | :metadata => { "username" => "quentin", | ||
| 25 | "human_readable_node_name" => "Subject" }.merge(metadata)) | ||
| 26 | end | ||
| 27 | |||
| 28 | test "publish renders the revision sentence, not the titles" do | ||
| 29 | out = action_summary(entry("publish", | ||
| 30 | { "via" => "draft", "title" => { "from" => "Old", "to" => "New" } })) | ||
| 31 | |||
| 32 | assert_includes out, "quentin" | ||
| 33 | assert_includes out, "new revision" | ||
| 34 | assert_not_includes out, "Old" | ||
| 35 | end | ||
| 36 | |||
| 37 | test "first publish uses its own sentence" do | ||
| 38 | out = action_summary(entry("publish", | ||
| 39 | { "via" => "draft", "title" => { "from" => nil, "to" => "New" } })) | ||
| 40 | |||
| 41 | assert_includes out, "for the first time" | ||
| 42 | end | ||
| 43 | |||
| 44 | test "rollback publishes get the rollback sentence" do | ||
| 45 | out = action_summary(entry("publish", | ||
| 46 | { "via" => "revision", "title" => { "from" => "Now", "to" => "Then" } })) | ||
| 47 | |||
| 48 | assert_includes out, "rolled" | ||
| 49 | end | ||
| 50 | |||
| 51 | test "move renders the path pair" do | ||
| 52 | out = action_summary(entry("move", | ||
| 53 | { "path" => { "from" => "a/b", "to" => "a/c" } })) | ||
| 54 | |||
| 55 | assert_includes out, "a/b" | ||
| 56 | assert_includes out, "a/c" | ||
| 57 | end | ||
| 58 | |||
| 59 | test "unknown verbs degrade to a generic sentence, never an error" do | ||
| 60 | out = action_summary(entry("frobnicate")) | ||
| 61 | |||
| 62 | assert_includes out, "frobnicate" | ||
| 63 | assert_includes out, "quentin" | ||
| 64 | end | ||
| 65 | |||
| 66 | test "dead references render as plain names from metadata, no links" do | ||
| 67 | out = action_summary(entry("publish", | ||
| 68 | { "title" => { "from" => "Old", "to" => "New" } })) | ||
| 69 | |||
| 70 | assert_not_includes out, "<a " | ||
| 71 | assert_includes out, "Subject" | ||
| 72 | end | ||
| 73 | |||
| 74 | test "metadata values are escaped" do | ||
| 75 | out = action_summary(entry("publish", | ||
| 76 | { "human_readable_node_name" => "<b>bold</b>" })) | ||
| 77 | |||
| 78 | assert_not_includes out, "<b>" | ||
| 79 | end | ||
| 80 | |||
| 81 | test "live associations upgrade names to links" do | ||
| 82 | out = action_summary(entry("publish", | ||
| 83 | { "title" => { "from" => "Old", "to" => "New" } }, | ||
| 84 | :user => users(:quentin))) | ||
| 85 | |||
| 86 | assert_includes out, "<a " | ||
| 87 | end | ||
| 88 | |||
| 89 | test "details are guarded off when nothing but an unchanged title is present" do | ||
| 90 | unchanged = entry("publish", { "title" => { "from" => "Same", "to" => "Same" } }) | ||
| 91 | changed = entry("publish", { "title" => { "from" => "Old", "to" => "New" } }) | ||
| 92 | |||
| 93 | assert_not action_details?(unchanged) | ||
| 94 | assert action_details?(changed) | ||
| 95 | end | ||
| 96 | |||
| 97 | test "first publish with a byline names the author" do | ||
| 98 | out = action_summary(entry("publish", | ||
| 99 | { "title" => { "from" => nil, "to" => "New" }, | ||
| 100 | "author" => { "from" => nil, "to" => "quentin" } })) | ||
| 101 | |||
| 102 | assert_includes out, "author" | ||
| 103 | end | ||
| 104 | |||
| 105 | test "create entries with their flat title render and are guarded off details" do | ||
| 106 | e = entry("create", { "title" => "Initial", "path" => "a/b" }) | ||
| 107 | |||
| 108 | assert_includes action_summary(e), "a/b" | ||
| 109 | assert_not action_details?(e) | ||
| 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 | ||
| 146 | end | ||
diff --git a/test/models/node_action_test.rb b/test/models/node_action_test.rb new file mode 100644 index 0000000..b177cca --- /dev/null +++ b/test/models/node_action_test.rb | |||
| @@ -0,0 +1,128 @@ | |||
| 1 | require "test_helper" | ||
| 2 | |||
| 3 | class NodeActionTest < ActiveSupport::TestCase | ||
| 4 | |||
| 5 | # Standalone pages, no node -- same pattern autosave relies on. | ||
| 6 | # Default-locale attributes are written pinned, never ambient. | ||
| 7 | def build_page en: nil, **attrs | ||
| 8 | page = Globalize.with_locale(I18n.default_locale) do | ||
| 9 | Page.create!({ :title => "Titel" }.merge(attrs)) | ||
| 10 | end | ||
| 11 | page.translations.create!({ :locale => "en" }.merge(en)) if en | ||
| 12 | page.reload | ||
| 13 | end | ||
| 14 | |||
| 15 | test "first publish yields exactly the title pair, from nil" do | ||
| 16 | diff = NodeAction.head_diff(nil, build_page(:title => "Erstausgabe")) | ||
| 17 | |||
| 18 | assert_equal({ :title => { "from" => nil, "to" => "Erstausgabe" } }, diff) | ||
| 19 | end | ||
| 20 | |||
| 21 | test "first publish carries the byline when the page has one" do | ||
| 22 | diff = NodeAction.head_diff(nil, build_page(:user => users(:quentin))) | ||
| 23 | |||
| 24 | assert_equal({ "from" => nil, "to" => users(:quentin).login }, diff[:author]) | ||
| 25 | end | ||
| 26 | |||
| 27 | test "title pair is always present, even when unchanged" do | ||
| 28 | diff = NodeAction.head_diff(build_page, build_page) | ||
| 29 | |||
| 30 | assert_equal "Titel", diff[:title]["from"] | ||
| 31 | assert_equal "Titel", diff[:title]["to"] | ||
| 32 | end | ||
| 33 | |||
| 34 | test "author pair present only when the byline changed" do | ||
| 35 | u1, u2 = users(:quentin), users(:aaron) | ||
| 36 | |||
| 37 | assert_nil NodeAction.head_diff(build_page(:user => u1), build_page(:user => u1))[:author] | ||
| 38 | |||
| 39 | changed = NodeAction.head_diff(build_page(:user => u1), build_page(:user => u2)) | ||
| 40 | assert_equal({ "from" => u1.login, "to" => u2.login }, changed[:author]) | ||
| 41 | end | ||
| 42 | |||
| 43 | test "tags pair present only when the tag set changed" do | ||
| 44 | old_page = build_page; old_page.tag_list = "alpha, beta"; old_page.save! | ||
| 45 | new_page = build_page; new_page.tag_list = "beta, gamma"; new_page.save! | ||
| 46 | |||
| 47 | diff = NodeAction.head_diff(old_page.reload, new_page.reload) | ||
| 48 | assert_equal %w[alpha beta], diff[:tags]["from"] | ||
| 49 | assert_equal %w[beta gamma], diff[:tags]["to"] | ||
| 50 | |||
| 51 | same = NodeAction.head_diff(old_page, old_page) | ||
| 52 | assert_nil same[:tags] | ||
| 53 | end | ||
| 54 | |||
| 55 | test "template_changed flag only when true" do | ||
| 56 | old_page = build_page(:template_name => "standard_template") | ||
| 57 | |||
| 58 | assert NodeAction.head_diff(old_page, build_page(:template_name => "update"))[:template_changed] | ||
| 59 | assert_nil NodeAction.head_diff(old_page, build_page(:template_name => "standard_template"))[:template_changed] | ||
| 60 | end | ||
| 61 | |||
| 62 | test "assets_changed flag when the attached set differs" do | ||
| 63 | asset = Asset.create!(:name => "diff probe", | ||
| 64 | :upload_file_name => "test_image.png", | ||
| 65 | :upload_content_type => "image/png", | ||
| 66 | :upload_file_size => 49854, | ||
| 67 | :upload_updated_at => Time.current) | ||
| 68 | old_page, new_page = build_page, build_page | ||
| 69 | new_page.related_assets.create!(:asset_id => asset.id, :position => 1) | ||
| 70 | |||
| 71 | |||
| 72 | diff = NodeAction.head_diff(old_page, new_page.reload) | ||
| 73 | assert diff[:assets_changed] | ||
| 74 | assert_nil NodeAction.head_diff(old_page, old_page)[:assets_changed] | ||
| 75 | end | ||
| 76 | |||
| 77 | test "default-locale abstract and body changes become flags, only when true" do | ||
| 78 | diff = NodeAction.head_diff(build_page(:abstract => "a"), build_page(:abstract => "b")) | ||
| 79 | |||
| 80 | assert diff[:abstract_changed] | ||
| 81 | assert_nil diff[:body_changed] | ||
| 82 | end | ||
| 83 | |||
| 84 | test "added locale carries status and the new title" do | ||
| 85 | diff = NodeAction.head_diff(build_page, build_page(en: { :title => "English" })) | ||
| 86 | |||
| 87 | entry = diff[:translation_diff]["en"] | ||
| 88 | assert_equal "added", entry["status"] | ||
| 89 | assert_equal({ "from" => nil, "to" => "English" }, entry["title"]) | ||
| 90 | end | ||
| 91 | |||
| 92 | test "removed locale keeps the vanished title" do | ||
| 93 | diff = NodeAction.head_diff(build_page(en: { :title => "English" }), build_page) | ||
| 94 | |||
| 95 | entry = diff[:translation_diff]["en"] | ||
| 96 | assert_equal "removed", entry["status"] | ||
| 97 | assert_equal "English", entry["title"]["from"] | ||
| 98 | assert_nil entry["title"]["to"] | ||
| 99 | end | ||
| 100 | |||
| 101 | test "changed locale carries only what actually differs" do | ||
| 102 | old_page = build_page(en: { :title => "Same", :body => "old" }) | ||
| 103 | new_page = build_page(en: { :title => "Same", :body => "new" }) | ||
| 104 | |||
| 105 | entry = NodeAction.head_diff(old_page, new_page)[:translation_diff]["en"] | ||
| 106 | assert_equal "changed", entry["status"] | ||
| 107 | assert_nil entry["title"] | ||
| 108 | assert entry["body_changed"] | ||
| 109 | assert_nil entry["abstract_changed"] | ||
| 110 | end | ||
| 111 | |||
| 112 | test "untouched locales are absent; identical pages yield no translation_diff at all" do | ||
| 113 | old_page = build_page(en: { :title => "Same" }) | ||
| 114 | new_page = build_page(en: { :title => "Same" }) | ||
| 115 | |||
| 116 | assert_nil NodeAction.head_diff(old_page, new_page)[:translation_diff] | ||
| 117 | end | ||
| 118 | |||
| 119 | test "record! passes provenance and historical timestamps through" do | ||
| 120 | long_ago = 2.years.ago | ||
| 121 | action = NodeAction.record!(:node => nil, :action => "publish", | ||
| 122 | :occurred_at => long_ago, | ||
| 123 | :inferred_from => "from_page_revision") | ||
| 124 | |||
| 125 | assert_equal "from_page_revision", action.inferred_from | ||
| 126 | assert_in_delta long_ago.to_f, action.occurred_at.to_f, 1.0 | ||
| 127 | end | ||
| 128 | end | ||
diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 022fff6..ab66f81 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb | |||
| @@ -633,6 +633,7 @@ class NodeTest < ActiveSupport::TestCase | |||
| 633 | assert_equal node.head, action.page | 633 | assert_equal node.head, action.page |
| 634 | assert_equal @user1, action.user | 634 | assert_equal @user1, action.user |
| 635 | assert_equal "publish", action.action | 635 | assert_equal "publish", action.action |
| 636 | assert_equal "draft", action.metadata["via"] | ||
| 636 | end | 637 | end |
| 637 | 638 | ||
| 638 | test "publish_draft! called with no user logs no actor, not a guessed one" do | 639 | test "publish_draft! called with no user logs no actor, not a guessed one" do |
| @@ -692,7 +693,7 @@ class NodeTest < ActiveSupport::TestCase | |||
| 692 | assert_equal count_before, NodeAction.count | 693 | assert_equal count_before, NodeAction.count |
| 693 | end | 694 | end |
| 694 | 695 | ||
| 695 | test "publish_draft! records the title's from/to in metadata" do | 696 | test "publish_draft! records the title diff in metadata" do |
| 696 | node = create_node_with_published_page | 697 | node = create_node_with_published_page |
| 697 | Globalize.with_locale(:de) { node.head.update!(:title => "Original Title") } | 698 | Globalize.with_locale(:de) { node.head.update!(:title => "Original Title") } |
| 698 | find_or_create_draft(node, @user1) | 699 | find_or_create_draft(node, @user1) |
| @@ -701,7 +702,164 @@ class NodeTest < ActiveSupport::TestCase | |||
| 701 | node.publish_draft!(@user1) | 702 | node.publish_draft!(@user1) |
| 702 | 703 | ||
| 703 | action = NodeAction.last | 704 | action = NodeAction.last |
| 704 | assert_equal "Original Title", action.metadata["from"] | 705 | assert_equal "draft", action.metadata["via"] |
| 705 | assert_equal "New Title", action.metadata["to"] | 706 | assert_equal "Original Title", action.metadata.dig("title", "from") |
| 707 | assert_equal "New Title", action.metadata.dig("title", "to") | ||
| 708 | end | ||
| 709 | |||
| 710 | test "publishing a staged slug change logs a move with the path pair" do | ||
| 711 | node = create_node_with_published_page | ||
| 712 | path_before = node.unique_name | ||
| 713 | node.staged_slug = "moved-#{node.slug}" | ||
| 714 | node.save! | ||
| 715 | publish_count_before = NodeAction.where(:action => "publish").count | ||
| 716 | |||
| 717 | node.publish_draft!(@user1) | ||
| 718 | |||
| 719 | node.reload | ||
| 720 | assert_not_equal path_before, node.unique_name | ||
| 721 | |||
| 722 | action = NodeAction.where(:action => "move").last | ||
| 723 | assert_equal node, action.node | ||
| 724 | assert_equal @user1, action.user | ||
| 725 | assert_equal path_before, action.metadata.dig("path", "from") | ||
| 726 | assert_equal node.unique_name, action.metadata.dig("path", "to") | ||
| 727 | |||
| 728 | # No draft was pending: path change alone must not fabricate a publish. | ||
| 729 | assert_equal publish_count_before, NodeAction.where(:action => "publish").count | ||
| 730 | end | ||
| 731 | |||
| 732 | test "publishing a draft together with a staged move logs two entries" do | ||
| 733 | node = create_node_with_published_page | ||
| 734 | find_or_create_draft(node, @user1) | ||
| 735 | node.staged_slug = "relocated-#{node.slug}" | ||
| 736 | node.save! | ||
| 737 | |||
| 738 | assert_difference "NodeAction.count", 2 do | ||
| 739 | node.publish_draft!(@user1) | ||
| 740 | end | ||
| 741 | |||
| 742 | assert_equal %w[move publish], | ||
| 743 | NodeAction.order(:id).last(2).map(&:action).sort | ||
| 744 | end | ||
| 745 | |||
| 746 | test "restore_revision! logs a publish via revision" do | ||
| 747 | node = create_node_with_published_page | ||
| 748 | Globalize.with_locale(:de) { node.head.update!(:title => "First") } | ||
| 749 | first_head = node.head | ||
| 750 | find_or_create_draft(node, @user1) | ||
| 751 | Globalize.with_locale(:de) { node.draft.update!(:title => "Second") } | ||
| 752 | node.publish_draft!(@user1) | ||
| 753 | |||
| 754 | node.restore_revision!(first_head.revision, @user1) | ||
| 755 | |||
| 756 | action = NodeAction.last | ||
| 757 | assert_equal "publish", action.action | ||
| 758 | assert_equal "revision", action.metadata["via"] | ||
| 759 | assert_equal first_head, action.page | ||
| 760 | assert_equal @user1, action.user | ||
| 761 | assert_equal "Second", action.metadata.dig("title", "from") | ||
| 762 | assert_equal "First", action.metadata.dig("title", "to") | ||
| 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! } | ||
| 706 | end | 864 | end |
| 707 | 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 = {}) |
