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.
- checksums.yaml +4 -4
- data/Gemfile +4 -3
- data/Gemfile.lock +127 -111
- data/README.md +2 -0
- data/app/controllers/admin/resource_controller.rb +1 -1
- data/app/controllers/application_controller.rb +15 -0
- data/app/models/asset.rb +29 -5
- data/app/models/standard_tags.rb +13 -7
- data/app/models/user.rb +13 -0
- data/lib/string_extensions/string_extensions.rb +4 -2
- data/lib/trusty_cms/site_scope_auth_reporter.rb +108 -0
- data/lib/trusty_cms/version.rb +1 -1
- data/package.json +1 -1
- data/spec/controllers/admin/assets_controller_spec.rb +124 -0
- data/spec/controllers/admin/changes_controller_spec.rb +72 -0
- data/spec/controllers/admin/configuration_controller_spec.rb +49 -0
- data/spec/controllers/admin/page_attachments_controller_spec.rb +54 -0
- data/spec/controllers/admin/pages_controller_spec.rb +162 -0
- data/spec/controllers/admin/preferences_controller_spec.rb +44 -0
- data/spec/controllers/admin/security_controller_spec.rb +85 -0
- data/spec/controllers/admin/sessions_controller_spec.rb +56 -0
- data/spec/controllers/admin/snippets_controller_spec.rb +95 -0
- data/spec/controllers/admin/two_factor_controller_spec.rb +63 -0
- data/spec/controllers/admin/users_controller_spec.rb +132 -0
- data/spec/controllers/page_status_controller_spec.rb +66 -0
- data/spec/controllers/site_controller_spec.rb +63 -0
- data/spec/dummy/bin/rails +3 -3
- data/spec/dummy/bin/rake +2 -2
- data/spec/dummy/bin/setup +21 -13
- data/spec/dummy/config/boot.rb +1 -1
- data/spec/dummy/config/cable.yml +10 -0
- data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +6 -2
- data/spec/dummy/config/initializers/inflections.rb +4 -4
- data/spec/dummy/config/initializers/new_framework_defaults_7_2.rb +70 -0
- data/spec/dummy/config/initializers/permissions_policy.rb +13 -0
- data/spec/dummy/config/puma.rb +34 -0
- data/spec/dummy/config/storage.yml +28 -1
- data/spec/dummy/db/migrate/20260729201444_add_service_name_to_active_storage_blobs.active_storage.rb +22 -0
- data/spec/dummy/db/migrate/20260729201445_create_active_storage_variant_records.active_storage.rb +27 -0
- data/spec/dummy/db/migrate/20260729201446_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +8 -0
- data/spec/dummy/public/404.html +6 -6
- data/spec/dummy/public/406-unsupported-browser.html +66 -0
- data/spec/dummy/public/422.html +6 -6
- data/spec/dummy/public/500.html +6 -6
- data/spec/dummy/public/icon.png +0 -0
- data/spec/dummy/public/icon.svg +3 -0
- data/spec/dummy/public/robots.txt +1 -0
- data/spec/factories/site.rb +8 -0
- data/spec/helpers/admin/configuration_helper_spec.rb +62 -0
- data/spec/helpers/admin/node_helper_spec.rb +132 -0
- data/spec/helpers/admin/pages_helper_spec.rb +84 -0
- data/spec/helpers/admin/references_helper_spec.rb +54 -0
- data/spec/helpers/admin/url_helper_spec.rb +71 -0
- data/spec/helpers/application_helper_spec.rb +217 -0
- data/spec/helpers/scoped_helper_spec.rb +60 -0
- data/spec/lib/active_record_extensions_spec.rb +65 -0
- data/spec/lib/login_system_spec.rb +61 -0
- data/spec/lib/ostruct_spec.rb +33 -0
- data/spec/lib/string_extensions_spec.rb +89 -0
- data/spec/lib/symbol_extensions_spec.rb +14 -0
- data/spec/lib/trusty_cms/config_cache_spec.rb +73 -0
- data/spec/models/admins_site_spec.rb +19 -0
- data/spec/models/asset_spec.rb +224 -0
- data/spec/models/asset_type_spec.rb +137 -0
- data/spec/models/file_not_found_page_spec.rb +33 -0
- data/spec/models/haml_filter_spec.rb +30 -0
- data/spec/models/menu_renderer_spec.rb +63 -0
- data/spec/models/page_attachment_spec.rb +29 -0
- data/spec/models/page_context_spec.rb +57 -0
- data/spec/models/page_field_spec.rb +15 -0
- data/spec/models/page_part_spec.rb +33 -0
- data/spec/models/page_spec.rb +184 -0
- data/spec/models/rails_page_spec.rb +32 -0
- data/spec/models/site_spec.rb +93 -0
- data/spec/models/snippet_finder_spec.rb +27 -0
- data/spec/models/snippet_tags_spec.rb +45 -0
- data/spec/models/standard_tags/children_spec.rb +159 -0
- data/spec/models/standard_tags/content_spec.rb +168 -0
- data/spec/models/standard_tags/meta_spec.rb +86 -0
- data/spec/models/standard_tags_spec.rb +32 -0
- data/spec/models/status_spec.rb +63 -0
- data/spec/models/text_filter_spec.rb +47 -0
- data/spec/models/trusty_cms/config_spec.rb +69 -0
- data/spec/models/trusty_cms/page_response_cache_director_spec.rb +72 -0
- data/spec/models/user_action_observer_spec.rb +39 -0
- data/spec/models/user_serialize_telemetry_spec.rb +124 -0
- data/spec/rails_helper.rb +13 -6
- data/spec/requests/admin/snippets_spec.rb +81 -0
- data/spec/spec_helper.rb +17 -3
- data/spec/support/active_record_encryption.rb +10 -0
- data/spec/support/asset_type_registry.rb +29 -0
- data/trusty_cms.gemspec +4 -4
- data/yarn.lock +273 -259
- metadata +157 -14
- 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(
|
|
18
|
-
|
|
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
|
data/lib/trusty_cms/version.rb
CHANGED
data/package.json
CHANGED
|
@@ -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
|