trusty-cms 7.1.2 → 7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +4 -3
  3. data/Gemfile.lock +127 -111
  4. data/README.md +2 -0
  5. data/app/controllers/admin/resource_controller.rb +1 -1
  6. data/app/controllers/application_controller.rb +15 -0
  7. data/app/models/asset.rb +29 -5
  8. data/app/models/standard_tags.rb +13 -7
  9. data/app/models/user.rb +13 -0
  10. data/lib/string_extensions/string_extensions.rb +4 -2
  11. data/lib/trusty_cms/site_scope_auth_reporter.rb +108 -0
  12. data/lib/trusty_cms/version.rb +1 -1
  13. data/package.json +1 -1
  14. data/spec/controllers/admin/assets_controller_spec.rb +124 -0
  15. data/spec/controllers/admin/changes_controller_spec.rb +72 -0
  16. data/spec/controllers/admin/configuration_controller_spec.rb +49 -0
  17. data/spec/controllers/admin/page_attachments_controller_spec.rb +54 -0
  18. data/spec/controllers/admin/pages_controller_spec.rb +162 -0
  19. data/spec/controllers/admin/preferences_controller_spec.rb +44 -0
  20. data/spec/controllers/admin/security_controller_spec.rb +85 -0
  21. data/spec/controllers/admin/sessions_controller_spec.rb +56 -0
  22. data/spec/controllers/admin/snippets_controller_spec.rb +95 -0
  23. data/spec/controllers/admin/two_factor_controller_spec.rb +63 -0
  24. data/spec/controllers/admin/users_controller_spec.rb +132 -0
  25. data/spec/controllers/page_status_controller_spec.rb +66 -0
  26. data/spec/controllers/site_controller_spec.rb +63 -0
  27. data/spec/dummy/bin/rails +3 -3
  28. data/spec/dummy/bin/rake +2 -2
  29. data/spec/dummy/bin/setup +21 -13
  30. data/spec/dummy/config/boot.rb +1 -1
  31. data/spec/dummy/config/cable.yml +10 -0
  32. data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
  33. data/spec/dummy/config/initializers/filter_parameter_logging.rb +6 -2
  34. data/spec/dummy/config/initializers/inflections.rb +4 -4
  35. data/spec/dummy/config/initializers/new_framework_defaults_7_2.rb +70 -0
  36. data/spec/dummy/config/initializers/permissions_policy.rb +13 -0
  37. data/spec/dummy/config/puma.rb +34 -0
  38. data/spec/dummy/config/storage.yml +28 -1
  39. data/spec/dummy/db/migrate/20260729201444_add_service_name_to_active_storage_blobs.active_storage.rb +22 -0
  40. data/spec/dummy/db/migrate/20260729201445_create_active_storage_variant_records.active_storage.rb +27 -0
  41. data/spec/dummy/db/migrate/20260729201446_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +8 -0
  42. data/spec/dummy/public/404.html +6 -6
  43. data/spec/dummy/public/406-unsupported-browser.html +66 -0
  44. data/spec/dummy/public/422.html +6 -6
  45. data/spec/dummy/public/500.html +6 -6
  46. data/spec/dummy/public/icon.png +0 -0
  47. data/spec/dummy/public/icon.svg +3 -0
  48. data/spec/dummy/public/robots.txt +1 -0
  49. data/spec/factories/site.rb +8 -0
  50. data/spec/helpers/admin/configuration_helper_spec.rb +62 -0
  51. data/spec/helpers/admin/node_helper_spec.rb +132 -0
  52. data/spec/helpers/admin/pages_helper_spec.rb +84 -0
  53. data/spec/helpers/admin/references_helper_spec.rb +54 -0
  54. data/spec/helpers/admin/url_helper_spec.rb +71 -0
  55. data/spec/helpers/application_helper_spec.rb +217 -0
  56. data/spec/helpers/scoped_helper_spec.rb +60 -0
  57. data/spec/lib/active_record_extensions_spec.rb +65 -0
  58. data/spec/lib/login_system_spec.rb +61 -0
  59. data/spec/lib/ostruct_spec.rb +33 -0
  60. data/spec/lib/string_extensions_spec.rb +89 -0
  61. data/spec/lib/symbol_extensions_spec.rb +14 -0
  62. data/spec/lib/trusty_cms/config_cache_spec.rb +73 -0
  63. data/spec/models/admins_site_spec.rb +19 -0
  64. data/spec/models/asset_spec.rb +224 -0
  65. data/spec/models/asset_type_spec.rb +137 -0
  66. data/spec/models/file_not_found_page_spec.rb +33 -0
  67. data/spec/models/haml_filter_spec.rb +30 -0
  68. data/spec/models/menu_renderer_spec.rb +63 -0
  69. data/spec/models/page_attachment_spec.rb +29 -0
  70. data/spec/models/page_context_spec.rb +57 -0
  71. data/spec/models/page_field_spec.rb +15 -0
  72. data/spec/models/page_part_spec.rb +33 -0
  73. data/spec/models/page_spec.rb +184 -0
  74. data/spec/models/rails_page_spec.rb +32 -0
  75. data/spec/models/site_spec.rb +93 -0
  76. data/spec/models/snippet_finder_spec.rb +27 -0
  77. data/spec/models/snippet_tags_spec.rb +45 -0
  78. data/spec/models/standard_tags/children_spec.rb +159 -0
  79. data/spec/models/standard_tags/content_spec.rb +168 -0
  80. data/spec/models/standard_tags/meta_spec.rb +86 -0
  81. data/spec/models/standard_tags_spec.rb +32 -0
  82. data/spec/models/status_spec.rb +63 -0
  83. data/spec/models/text_filter_spec.rb +47 -0
  84. data/spec/models/trusty_cms/config_spec.rb +69 -0
  85. data/spec/models/trusty_cms/page_response_cache_director_spec.rb +72 -0
  86. data/spec/models/user_action_observer_spec.rb +39 -0
  87. data/spec/models/user_serialize_telemetry_spec.rb +124 -0
  88. data/spec/rails_helper.rb +13 -6
  89. data/spec/requests/admin/snippets_spec.rb +81 -0
  90. data/spec/spec_helper.rb +17 -3
  91. data/spec/support/active_record_encryption.rb +10 -0
  92. data/spec/support/asset_type_registry.rb +29 -0
  93. data/trusty_cms.gemspec +4 -4
  94. data/yarn.lock +273 -259
  95. metadata +157 -14
  96. data/spec/controllers/assets_controller.rb +0 -66
data/app/models/user.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require 'trusty_cms/site_scope_auth_reporter'
2
+
1
3
  class User < ActiveRecord::Base
2
4
  has_many :pages, foreign_key: :created_by_id
3
5
  self.table_name = 'admins'
@@ -78,4 +80,15 @@ class User < ActiveRecord::Base
78
80
  errors.add :password, 'Complexity requirement not met. Length should be 12 characters and include: 1 uppercase, 1 lowercase, 1 digit and 1 special character.'
79
81
  end
80
82
 
83
+ # Observe-only instrument for the intermittent logout bug (issue #1040). This
84
+ # keeps Devise's exact (site-scoped) behavior — it returns the scoped lookup
85
+ # unchanged, so a mismatch still logs the user out exactly as before — and only
86
+ # reports when a valid user was excluded by the site scope. See
87
+ # TrustyCms::SiteScopeAuthReporter. The behavior-change fix is separate (PR #1041).
88
+ def self.serialize_from_session(key, salt)
89
+ record = to_adapter.get(key)
90
+ TrustyCms::SiteScopeAuthReporter.report_miss(key) if record.nil?
91
+ record if record && record.authenticatable_salt == salt
92
+ end
93
+
81
94
  end
@@ -14,8 +14,10 @@ class String
14
14
  end
15
15
 
16
16
  unless methods.include?('parameterize')
17
- def parameterize(sep = '-')
18
- remove_formatting.downcase.replace_whitespace(sep).collapse(sep)
17
+ def parameterize(separator = '-', preserve_case: false, locale: nil)
18
+ separator = '-' unless separator.is_a?(String)
19
+ slug = remove_formatting.replace_whitespace(separator).collapse(separator)
20
+ preserve_case ? slug : slug.downcase
19
21
  end
20
22
  end
21
23
 
@@ -0,0 +1,108 @@
1
+ module TrustyCms
2
+ # Observe-only telemetry for the intermittent CMS logout bug (diagnostic for
3
+ # issue #1040). It changes NO behavior.
4
+ #
5
+ # When the multi_site extension is active, `User` carries a site-scoped
6
+ # default_scope (an INNER JOIN on admins_sites for `Page.current_site`).
7
+ # Devise deserializes the session user every request through that scope, so if
8
+ # `current_site` points at a site the user is not assigned to, the lookup
9
+ # returns nil and the user is logged out. Because `current_site` is
10
+ # process-global mutable state shared across Puma threads, we suspect this is
11
+ # usually a race (the user IS on a site they belong to, but the global was
12
+ # clobbered by a concurrent request for another host).
13
+ #
14
+ # This reporter fires only when a session lookup misses BUT the user actually
15
+ # exists (unscoped) — i.e. a valid user was excluded by the site scope — and
16
+ # records enough context to tell a race from a genuine cross-site access or an
17
+ # unmatched-host fallback. It never raises into the auth path.
18
+ module SiteScopeAuthReporter
19
+ MESSAGE = 'CMS site-scope logout (possible current_site race)'.freeze
20
+
21
+ module_function
22
+
23
+ def report_miss(key)
24
+ return unless multi_site_scoped?
25
+
26
+ excluded = User.unscoped { User.to_adapter.get(key) }
27
+ return if excluded.nil? # ordinary unknown/expired session — not our bug
28
+
29
+ context = build_context(excluded)
30
+ Rails.logger.warn("[multi_site] #{MESSAGE} #{context.to_json}")
31
+ Honeybadger.notify(MESSAGE, context: context) if defined?(Honeybadger)
32
+ nil
33
+ rescue StandardError => e
34
+ # Telemetry must never break authentication.
35
+ Rails.logger.error("[multi_site] SiteScopeAuthReporter error: #{e.class}: #{e.message}")
36
+ nil
37
+ end
38
+
39
+ # Only meaningful when User is actually site-scoped by the multi_site
40
+ # extension; otherwise the scoped and unscoped lookups are identical.
41
+ def multi_site_scoped?
42
+ User.respond_to?(:is_site_scoped?) && User.is_site_scoped?
43
+ end
44
+
45
+ def build_context(user)
46
+ current = current_site
47
+ host = Thread.current[:trusty_request_host]
48
+ expected = expected_site_for(host)
49
+ site_ids = user.site_ids
50
+
51
+ {
52
+ user_id: user.id,
53
+ user_admin: user.admin?,
54
+ user_site_ids: site_ids,
55
+ current_site_id: current&.id,
56
+ current_site_name: current&.name,
57
+ current_site_domain_blank: current.present? && current.domain.blank?,
58
+ request_host: host,
59
+ expected_site_id: expected&.id,
60
+ expected_site_name: expected&.name,
61
+ classification: classify(site_ids, current, expected, host),
62
+ thread: Thread.current.object_id,
63
+ at: Time.now.utc.iso8601,
64
+ }
65
+ end
66
+
67
+ # race -> user belongs to the host's site, but current_site is
68
+ # something else (the global was clobbered)
69
+ # genuine_cross_site -> user hit a host whose site they are not assigned to
70
+ # host_unmatched -> request host matched no site (asset/unknown host);
71
+ # current_site falls back to the default site
72
+ # no_host_captured -> ran outside a captured request (e.g. rake/console)
73
+ def classify(site_ids, current, expected, host)
74
+ return 'no_host_captured' if host.blank?
75
+ return 'host_unmatched' if expected.nil?
76
+ return 'race' if site_ids.include?(expected.id) && (current.nil? || site_ids.exclude?(current.id))
77
+ return 'genuine_cross_site' if site_ids.exclude?(expected.id)
78
+
79
+ 'unknown'
80
+ end
81
+
82
+ def current_site
83
+ Page.current_site if Page.respond_to?(:current_site)
84
+ end
85
+
86
+ # Mirrors Site.find_for_host's matching but WITHOUT the Site.default /
87
+ # catchall fallback, so telemetry never creates a record and returns nil when
88
+ # the host matches no site (which is what drives the host_unmatched
89
+ # classification). A blank domain compiles to a regexp that matches every
90
+ # host, so blank domains are matched only by exact base_domain, never by the
91
+ # regexp.
92
+ def expected_site_for(host)
93
+ return nil if host.blank?
94
+
95
+ Site.all.detect do |site|
96
+ host == site.base_domain || domain_regexp_match?(site, host)
97
+ end
98
+ end
99
+
100
+ def domain_regexp_match?(site, host)
101
+ return false if site.domain.blank?
102
+
103
+ !(host =~ Regexp.compile(site.domain)).nil?
104
+ rescue RegexpError
105
+ false
106
+ end
107
+ end
108
+ end
@@ -1,3 +1,3 @@
1
1
  module TrustyCms
2
- VERSION = '7.1.2'.freeze
2
+ VERSION = '7.2'.freeze
3
3
  end
data/package.json CHANGED
@@ -22,7 +22,7 @@
22
22
  "tablesaw": "^3.1.2"
23
23
  },
24
24
  "devDependencies": {
25
- "esbuild": "^0.25.0",
25
+ "esbuild": "^0.28.1",
26
26
  "stylelint": "^13.3.2",
27
27
  "stylelint-config-recommended": "^3.0.0",
28
28
  "stylelint-order": "^4.0.0",
@@ -0,0 +1,124 @@
1
+ require 'spec_helper'
2
+ require 'rack/test'
3
+
4
+ # Exercises Admin::AssetsController, which overrides the ResourceController CRUD
5
+ # with custom upload handling (process_uploaded_asset, maybe_compress,
6
+ # failure_response, set_owner_or_editor). Uses the JSON/partial endpoints so the
7
+ # specs don't depend on the asset-pipeline-heavy admin HTML layout. High-value
8
+ # regression net for a Rails/Ruby upgrade — file uploads and content-type
9
+ # negotiation are common breakage points.
10
+ RSpec.describe Admin::AssetsController, type: :controller do
11
+ routes { TrustyCms::Application.routes }
12
+
13
+ let(:user) { create(:admin) }
14
+ let(:fixtures_path) { TrustyCms::Engine.root.join('spec', 'fixtures', 'files') }
15
+
16
+ before do
17
+ # approved_content_types derives from the AssetType registry, which is empty
18
+ # by default; register the canonical set (shared with asset_spec) so uploads
19
+ # of text/plain, images, etc. can be approved. See AssetTypeRegistry.
20
+ register_standard_asset_types
21
+
22
+ # public_url generation needs a host; controller specs don't set one on the
23
+ # Disk service the way a real request would.
24
+ @previous_active_storage_url_options = ActiveStorage::Current.url_options
25
+ ActiveStorage::Current.url_options = { host: 'test.host', protocol: 'http' }
26
+
27
+ allow(controller).to receive(:authenticate_user!).and_return(true)
28
+ allow(controller).to receive(:current_user).and_return(user)
29
+ end
30
+
31
+ after do
32
+ ActiveStorage::Current.url_options = @previous_active_storage_url_options
33
+ end
34
+
35
+ # A plain stub (not an rspec double) so it can be assigned in an `around`
36
+ # hook, which runs outside the per-test double lifecycle. api_key: nil makes
37
+ # should_compress? false, so uploads skip Kraken compression.
38
+ around do |example|
39
+ original_kraken = defined?($kraken) ? $kraken : nil
40
+ $kraken = Struct.new(:api_key).new(nil)
41
+ example.run
42
+ $kraken = original_kraken
43
+ end
44
+
45
+ def upload(filename, content_type)
46
+ Rack::Test::UploadedFile.new(fixtures_path.join(filename), content_type)
47
+ end
48
+
49
+ describe 'POST #uploader' do
50
+ it 'returns the asset url when the upload succeeds' do
51
+ expect {
52
+ post :uploader, params: { upload: upload('sample.txt', 'text/plain') }, format: :json
53
+ }.to change(Asset, :count).by(1)
54
+
55
+ expect(response).to have_http_status(:ok)
56
+ expect(JSON.parse(response.body)).to have_key('url')
57
+ end
58
+
59
+ it 'records the uploading user as owner/editor' do
60
+ post :uploader, params: { upload: upload('sample.txt', 'text/plain') }, format: :json
61
+
62
+ asset = Asset.last
63
+ expect(asset.created_by_id).to eq(user.id)
64
+ expect(asset.updated_by_id).to eq(user.id)
65
+ end
66
+
67
+ it 'returns unsupported media type for disallowed content types' do
68
+ expect(Asset).not_to receive(:create)
69
+
70
+ post :uploader, params: { upload: upload('sample.txt', 'application/x-unsupported') }, format: :json
71
+
72
+ expect(response).to have_http_status(:unsupported_media_type)
73
+ expect(JSON.parse(response.body)['error']).to eq('Unsupported file type.')
74
+ expect(flash[:error]).to eq('Unsupported file type.')
75
+ end
76
+
77
+ it 'rejects the generic octet-stream content type with a notice' do
78
+ post :uploader, params: { upload: upload('sample.txt', 'application/octet-stream') }, format: :json
79
+
80
+ expect(response).to have_http_status(:unprocessable_entity)
81
+ expect(JSON.parse(response.body)['error']).to match(/valid extension/)
82
+ expect(flash[:notice]).to match(/valid extension/)
83
+ end
84
+
85
+ it 'returns unprocessable entity when no file is provided' do
86
+ post :uploader, params: {}, format: :json
87
+
88
+ expect(response).to have_http_status(:unprocessable_entity)
89
+ expect(JSON.parse(response.body)['error']).to eq('No file uploaded.')
90
+ expect(flash[:error]).to eq('No file uploaded.')
91
+ end
92
+ end
93
+
94
+ describe 'POST #create' do
95
+ it 'creates an asset from an uploaded file and redirects (HTML)' do
96
+ expect {
97
+ post :create, params: { asset: { asset: [upload('sample.txt', 'text/plain')] } }
98
+ }.to change(Asset, :count).by(1)
99
+
100
+ expect(response).to be_redirect
101
+ end
102
+
103
+ it 'redirects back to new with a flash error when the upload is unsupported' do
104
+ expect {
105
+ post :create, params: { asset: { asset: [upload('sample.txt', 'application/x-unsupported')] } }
106
+ }.not_to change(Asset, :count)
107
+
108
+ expect(response).to redirect_to(new_admin_asset_path)
109
+ expect(flash[:error]).to eq('Unsupported file type.')
110
+ end
111
+ end
112
+
113
+ describe 'DELETE #destroy' do
114
+ it 'destroys the asset and redirects' do
115
+ asset = Asset.create!(asset: upload('sample.txt', 'text/plain'), caption: '')
116
+
117
+ expect {
118
+ delete :destroy, params: { id: asset.id }
119
+ }.to change(Asset, :count).by(-1)
120
+
121
+ expect(response).to be_redirect
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,72 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::ChangesController, which builds a PaperTrail-backed diff view
4
+ # of recent page changes. Controller specs (views not rendered) let us drive the
5
+ # action and its private diff-building helpers without the admin HTML layout.
6
+ # PaperTrail behaviour is a plausible upgrade casualty, so this pins it down.
7
+ RSpec.describe Admin::ChangesController, type: :controller do
8
+ routes { TrustyCms::Application.routes }
9
+
10
+ let(:user) { create(:admin) }
11
+ let!(:site) { create(:site) }
12
+
13
+ before do
14
+ allow(controller).to receive(:authenticate_user!).and_return(true)
15
+ allow(controller).to receive(:current_user).and_return(user)
16
+ # The multi-site extension (which provides #current_site) isn't loaded in the
17
+ # dummy app, so stub it to the site our fixtures are scoped to.
18
+ allow(controller).to receive(:current_site).and_return(site)
19
+ end
20
+
21
+ # Create a page scoped to the current site and give it a versioned change so
22
+ # the controller has something to diff.
23
+ def page_with_change
24
+ page = create(:home, title: 'Original', site_id: site.id)
25
+ PaperTrail.request(whodunnit: user.id.to_s) do
26
+ page.update!(title: 'Updated Title')
27
+ end
28
+ page
29
+ end
30
+
31
+ # rails-controller-testing (which provides `assigns`) isn't in the bundle, so
32
+ # read the controller's assigned ivars directly.
33
+ def changes
34
+ controller.instance_variable_get(:@changes)
35
+ end
36
+
37
+ describe 'GET #show' do
38
+ it 'succeeds and assigns recent changes' do
39
+ page_with_change
40
+
41
+ get :show
42
+
43
+ expect(response).to have_http_status(:ok)
44
+ expect(changes).to be_present
45
+ end
46
+
47
+ it 'loads a single change when a version_id is given' do
48
+ page = page_with_change
49
+ version = PaperTrail::Version.where(item_type: 'Page', item_id: page.id).last
50
+
51
+ get :show, params: { version_id: version.id }
52
+
53
+ expect(response).to have_http_status(:ok)
54
+ expect(changes.size).to eq(1)
55
+ expect(changes.first[:id]).to eq(version.id)
56
+ end
57
+
58
+ it 'reports an error when the version_id is unknown' do
59
+ get :show, params: { version_id: 0 }
60
+
61
+ expect(changes).to be_empty
62
+ expect(controller.instance_variable_get(:@change_error)).to eq('Version ID not found.')
63
+ end
64
+
65
+ it 'succeeds with no changes present' do
66
+ get :show
67
+
68
+ expect(response).to have_http_status(:ok)
69
+ expect(changes).to eq([])
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,49 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::ConfigurationController, which batch-updates TrustyCms::Config
4
+ # entries inside a transaction and is admin-gated. Config plumbing is easy to
5
+ # break in a framework upgrade, so pin the happy path and the auth gate.
6
+ RSpec.describe Admin::ConfigurationController, type: :controller do
7
+ routes { TrustyCms::Application.routes }
8
+
9
+ let(:admin) { create(:admin) }
10
+
11
+ before do
12
+ allow(controller).to receive(:authenticate_user!).and_return(true)
13
+ allow(controller).to receive(:current_user).and_return(admin)
14
+ end
15
+
16
+ describe 'GET #show' do
17
+ it 'succeeds' do
18
+ get :show
19
+ expect(response).to have_http_status(:ok)
20
+ end
21
+ end
22
+
23
+ describe 'GET #edit' do
24
+ it 'succeeds for an admin' do
25
+ get :edit
26
+ expect(response).to have_http_status(:ok)
27
+ end
28
+ end
29
+
30
+ describe 'PUT #update' do
31
+ it 'persists the submitted config values and redirects to show' do
32
+ put :update, params: { trusty_config: { 'admin.title' => 'Renamed CMS' } }
33
+
34
+ expect(response).to redirect_to(action: :show)
35
+ expect(TrustyCms::Config['admin.title']).to eq('Renamed CMS')
36
+ end
37
+ end
38
+
39
+ describe 'authorization' do
40
+ it 'denies a non-admin access to edit' do
41
+ allow(controller).to receive(:current_user).and_return(create(:non_admin, email: 'plain@example.com'))
42
+
43
+ get :edit
44
+
45
+ expect(response).to be_redirect
46
+ expect(flash[:error]).to be_present
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+ require 'rack/test'
3
+
4
+ # Exercises Admin::PageAttachmentsController, whose custom #load_model builds a
5
+ # PageAttachment from an asset_id (+ optional page_id) and rescues a missing
6
+ # asset. Controller spec (views not rendered) drives the action and its
7
+ # before-action without needing the admin HTML layout.
8
+ RSpec.describe Admin::PageAttachmentsController, type: :controller do
9
+ routes { TrustyCms::Application.routes }
10
+
11
+ let(:user) { create(:admin) }
12
+ let(:fixtures_path) { TrustyCms::Engine.root.join('spec', 'fixtures', 'files') }
13
+ let(:asset) do
14
+ Asset.create!(asset: Rack::Test::UploadedFile.new(fixtures_path.join('sample.txt'), 'text/plain'), caption: '')
15
+ end
16
+
17
+ before do
18
+ register_standard_asset_types # shared canonical AssetTypes (see AssetTypeRegistry)
19
+ allow(controller).to receive(:authenticate_user!).and_return(true)
20
+ allow(controller).to receive(:current_user).and_return(user)
21
+ end
22
+
23
+ describe 'GET #new' do
24
+ it 'builds a PageAttachment for the asset with a new (unsaved) page' do
25
+ get :new, params: { asset_id: asset.id }
26
+
27
+ expect(response).to have_http_status(:ok)
28
+ model = controller.instance_variable_get(:@page_attachment)
29
+ expect(model).to be_a(PageAttachment)
30
+ expect(model.asset).to eq(asset)
31
+ expect(model.page).to be_a(Page).and be_new_record
32
+ end
33
+
34
+ it 'associates an existing page when a page_id is given' do
35
+ page = create(:home, title: 'Attach Target')
36
+
37
+ get :new, params: { asset_id: asset.id, page_id: page.id }
38
+
39
+ expect(controller.instance_variable_get(:@page)).to eq(page)
40
+ end
41
+
42
+ # NOTE: latent bug — load_model rescues a missing asset with
43
+ # `render nothing: true`, but the `nothing:` option was removed in Rails 5.1.
44
+ # It's now ignored, so Rails falls through to rendering the (nonexistent)
45
+ # `new` template and raises MissingTemplate instead of returning an empty
46
+ # body. Characterizing current behavior; the fix is `head :no_content`.
47
+ # This is exactly the kind of breakage a Rails 8 upgrade should clean up.
48
+ it 'raises MissingTemplate for a missing asset (broken rescue path)' do
49
+ expect {
50
+ get :new, params: { asset_id: 0 }
51
+ }.to raise_error(ActionView::MissingTemplate)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,162 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::PagesController — the most heavily customized ResourceController
4
+ # subclass (search, restore, per-site edit guards, meta-row setup, ordering).
5
+ # Controller specs (views not rendered) let us drive the actions and their many
6
+ # private helpers without the asset-pipeline-heavy admin HTML layout. This is the
7
+ # highest-blast-radius controller in the app, so keep it green across the Rails 8 bump.
8
+ RSpec.describe Admin::PagesController, type: :controller do
9
+ routes { TrustyCms::Application.routes }
10
+
11
+ let(:admin) { create(:admin) }
12
+ let!(:home) { create(:home, title: 'Home') }
13
+
14
+ before do
15
+ allow(controller).to receive(:authenticate_user!).and_return(true)
16
+ allow(controller).to receive(:current_user).and_return(admin)
17
+ # #current_site / Page.current_site / Page.homepage come from the multi-site
18
+ # extension, which isn't loaded in the dummy app; stub the touch points.
19
+ allow(Page).to receive(:current_site).and_return(nil)
20
+ allow(Page).to receive(:homepage).and_return(home)
21
+ end
22
+
23
+ describe 'GET #index' do
24
+ it 'succeeds and sets up the site/homepage/search' do
25
+ get :index
26
+ expect(response).to have_http_status(:ok)
27
+ expect(controller.instance_variable_get(:@homepage)).to eq(home)
28
+ end
29
+ end
30
+
31
+ describe 'GET #new' do
32
+ it 'builds a new page with defaults' do
33
+ get :new
34
+ expect(response).to have_http_status(:ok)
35
+ page = controller.instance_variable_get(:@page)
36
+ expect(page).to be_a(Page).and be_new_record
37
+ end
38
+
39
+ it 'roots a top-level page at "/" when no parent is given' do
40
+ get :new
41
+ expect(controller.instance_variable_get(:@page).slug).to eq('/')
42
+ end
43
+
44
+ it 'sets the parent_id from page_id' do
45
+ get :new, params: { page_id: home.id }
46
+ expect(controller.instance_variable_get(:@page).parent_id).to eq(home.id)
47
+ end
48
+ end
49
+
50
+ describe 'GET #edit' do
51
+ context 'when the page belongs to a site the admin can access' do
52
+ let(:site) { create(:site) }
53
+ let(:page) { create(:page, title: 'Editable', parent: home, site_id: site.id) }
54
+
55
+ before { admin.sites << site }
56
+
57
+ it 'succeeds and loads edit assigns' do
58
+ get :edit, params: { id: page.id }
59
+
60
+ expect(response).to have_http_status(:ok)
61
+ expect(controller.instance_variable_get(:@page)).to eq(page)
62
+ expect(controller.instance_variable_get(:@page_url)).to be_present
63
+ end
64
+ end
65
+
66
+ context 'when the page belongs to a site the admin cannot access' do
67
+ let(:site) { create(:site) }
68
+ let(:page) { create(:page, title: 'Forbidden', parent: home, site_id: site.id) }
69
+
70
+ it 'redirects back to the pages index' do
71
+ get :edit, params: { id: page.id }
72
+ expect(response).to redirect_to(admin_pages_url)
73
+ end
74
+ end
75
+ end
76
+
77
+ describe 'POST #create' do
78
+ it 'creates a page and redirects' do
79
+ expect {
80
+ post :create, params: { page: { title: 'Fresh', slug: 'fresh', breadcrumb: 'Fresh', parent_id: home.id } }
81
+ }.to change(Page, :count).by(1)
82
+
83
+ expect(response).to be_redirect
84
+ end
85
+
86
+ it 're-renders new via the rescue_from validation_error handler when invalid' do
87
+ expect {
88
+ post :create, params: { page: { title: '', slug: '', breadcrumb: '', parent_id: home.id } }
89
+ }.not_to change(Page, :count)
90
+
91
+ expect(flash[:error]).to be_present
92
+ end
93
+ end
94
+
95
+ describe 'PUT #update' do
96
+ it 'updates the page and redirects' do
97
+ page = create(:page, title: 'Before', parent: home)
98
+
99
+ put :update, params: { id: page.id, page: { title: 'After' } }
100
+
101
+ expect(response).to be_redirect
102
+ expect(page.reload.title).to eq('After')
103
+ end
104
+ end
105
+
106
+ describe 'DELETE #destroy' do
107
+ it 'destroys the page and counts descendants' do
108
+ page = create(:page, title: 'Doomed', parent: home)
109
+
110
+ expect {
111
+ delete :destroy, params: { id: page.id }
112
+ }.to change(Page, :count).by(-1)
113
+
114
+ expect(response).to be_redirect
115
+ end
116
+ end
117
+
118
+ describe 'GET #search' do
119
+ it 'returns matching pages when a query is present' do
120
+ create(:page, title: 'Findable', slug: 'findable', parent: home)
121
+
122
+ get :search, params: { site_id: nil, search: { query: 'Findable' } }
123
+
124
+ expect(response).to have_http_status(:ok)
125
+ titles = controller.instance_variable_get(:@pages).map(&:title)
126
+ expect(titles).to include('Findable')
127
+ end
128
+
129
+ it 'does not run a query when none is given' do
130
+ get :search, params: { site_id: '' }
131
+ expect(controller.instance_variable_get(:@pages)).to be_nil
132
+ end
133
+ end
134
+
135
+ describe 'POST #save_table_position' do
136
+ it 'saves the new order and returns ok' do
137
+ allow(Page).to receive(:save_order)
138
+ post :save_table_position, params: { new_position: %w[1 2 3] }
139
+ expect(response).to have_http_status(:ok)
140
+ expect(Page).to have_received(:save_order).with(%w[1 2 3])
141
+ end
142
+ end
143
+
144
+ describe 'PUT #restore' do
145
+ # NOTE: latent bug / Rails 8 upgrade blocker — restore_page_version calls
146
+ # PaperTrail's `reify`, which YAML-loads the stored version. Under Psych 4+
147
+ # (psych 5.4 is pinned) safe-loading rejects ActiveSupport::TimeWithZone
148
+ # because no permitted classes are configured, so restoring any page that has
149
+ # a timestamp in its versioned state raises Psych::DisallowedClass. Page
150
+ # restore is effectively broken until paper_trail's serializer is configured
151
+ # with permitted classes (e.g. via ActiveRecord::Base.yaml_column_permitted_classes
152
+ # or a custom serializer). Characterizing current behavior.
153
+ it 'currently fails to reify a versioned page (Psych safe-load blocker)' do
154
+ page = create(:page, title: 'V1', parent: home)
155
+ PaperTrail.request(whodunnit: admin.id.to_s) { page.update!(title: 'V2') }
156
+
157
+ expect {
158
+ put :restore, params: { id: page.id, version_index: 1 }
159
+ }.to raise_error(Psych::DisallowedClass, /TimeWithZone/)
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,44 @@
1
+ require 'spec_helper'
2
+
3
+ # Exercises Admin::PreferencesController, which lets the current user edit their
4
+ # own profile/preferences. Simple ApplicationController subclass; covers the
5
+ # show/edit render paths and the valid/invalid update branches.
6
+ RSpec.describe Admin::PreferencesController, type: :controller do
7
+ routes { TrustyCms::Application.routes }
8
+
9
+ let(:admin) { create(:admin) }
10
+
11
+ before do
12
+ allow(controller).to receive(:authenticate_user!).and_return(true)
13
+ allow(controller).to receive(:current_user).and_return(admin)
14
+ end
15
+
16
+ describe 'GET #show' do
17
+ it 'succeeds' do
18
+ get :show
19
+ expect(response).to have_http_status(:ok)
20
+ end
21
+ end
22
+
23
+ describe 'GET #edit' do
24
+ it 'succeeds' do
25
+ get :edit
26
+ expect(response).to have_http_status(:ok)
27
+ end
28
+ end
29
+
30
+ describe 'PUT #update' do
31
+ it 'updates the current user and redirects to configuration' do
32
+ put :update, params: { user: { first_name: 'Preferred' } }
33
+
34
+ expect(response).to redirect_to(admin_configuration_path)
35
+ expect(admin.reload.first_name).to eq('Preferred')
36
+ end
37
+
38
+ it 're-renders edit with an error when the update is invalid' do
39
+ put :update, params: { user: { email: 'not-an-email' } }
40
+
41
+ expect(flash[:error]).to be_present
42
+ end
43
+ end
44
+ end